RFC: Standard Error Handling Utility Package in gofr
Summary
This RFC proposes the introduction of a centralized error handling utility package within the gofr framework. At present, error handling across the framework is somewhat inconsistent and decentralized, with individual packages employing their own mechanisms for capturing and emitting errors. This proposal seeks to establish a standardized approach to error definition, propagation, and handling across the system, with the goal of enabling structured logging, consistent error categorization, and enhanced observability.
Motivation
- Inconsistency: Varying error-handling patterns across packages, which can introduce challenges in maintaining and scaling the codebase.
- Lack of standardisation: No unified severity model or error code mapping exists.
- Instrumentation gaps: Disparate error formats hinder effective structured logging and telemetry.
- Security: Risk of leaking sensitive information due to lack of standardised redaction protocols.
- Developer friction: Absence of a shared error vocabulary and mechanism creates additional overhead for new and existing developers.
Goals
- Provide a common language and model for errors.
- Enable flexibility in defining and configuring application-specific errors.
- Standardize severity levels to make errors more predictable and actionable.
- Enable structured logging and instrumentation by making errors machine-readable and codified.
- Separate internal errors from user-facing messages to avoid data leakage or confusion.
Design Requirements
- Use standard Go error interface as the foundation.
- Support a configuration schema for: - Error codes - Internal vs external classification - Severity levels (Info, Warning, Critical)
- Allow application developers to extend base error definitions with domain-specific ones.
Error Model
A standard error object should include the following fields:
type AppError struct {
// Unique error code, e.g., "USER_NOT_FOUND"
Code string
// user-facing
ExternalMessage string
// developer-facing
InternalMessage string
Severity SeverityLevel
// Wrapped error, optional
Cause error
// Additional context (safe for logging)
Meta map[string]interface{}
}
Severity levels could be defined as:
type SeverityLevel string
const (
// Indicates a potential problem that needs attention, but is not immediately critical. Example: low disk space or high CPU usage
SeverityWarning SeverityLevel = "warning"
// Issue that could lead to data loss or significant service disruption requiring immediate action. Example: application crashes
SeverityError SeverityLevel = "error"
// system-crashing error, requiring immediate action. Example: loss of database connection
SeverityCritical SeverityLevel = "critical"
)
Sample Usage
func GetUser(ctx context.Context, id string) error {
user, err := db.FindUserByID(id)
if err != nil {
return apperrors.
context(ctx).New(
"USER_NOT_FOUND",
"User does not exist.",
"no rows found in user table for given id",
apperrors.SeverityWarning,
err,
map[string]interface{}{"user_id": id},
)
}
return nil
}
Outcomes
- Centralized error handling
- Better developer experience and readability
- Minimized risk of leaking sensitive data
- Easier evolution of the error-handling model over time
Next Steps
- Build core utility package under pkg/errors in gofr.
- Establish a error code registry (file or config).
- Migrate modules to the new error model incrementally.
- Create developer documentation and usage guidelines.
Utility packages are an anti-pattern in go. (ref: https://go.dev/doc/effective_go)
Using a utility package in Go is considered an anti-pattern, especially when it contains a mix of unrelated functions thrown together to avoid cyclic dependencies or simply because there isn't a better place to put them.
However, if a package is focused on a specific feature, is useful, testable, and has a clear purpose, it aligns well with Go's design philosophy. A good example is the httputil package in the Go standard library.
I feel like it's a trap to consider this.
Let's put aside the implementation you suggest for the moment.
- exposing error struct/sentinel means you maintain them for ever
- error struct should be considered when they are close to the function they use. But not a framework level like this
- if there is a need to detect error outside gofr framework, it means there is a need to consider them inside the app that uses gofr
- its possible to join a sentinel error to all gofr and it would be enough. But this could also be done by anyone calling gofr framework in a wrapper method. So I'm unsure there is a need for it.
About the implementation itself now, I'm not fan of such complicated thing.
I feel it's better to focus on defining what are the current problems when using gofr, and not over think how an global error format could be.
The design of this package intentionally aims to keep the surface area minimal and semantically meaningful. Allow me to share more here
-
Exposing error types may translate to maintain them indefinitely but the idea here is to define a controlled set of common and reusable error types that represent categories rather than specific error cases. This improves consistency across the applications using gofr.
-
Defining local errors makes sense and we surely can encourage that. However at the framework level there is value in having standardized, composable error types that are shared across different layers of the stack. This becomes more important when multiple components (HTTP handler, middleware, logger, etc.) need to interpret or categorize them.
-
Also, the users could wrap gofr calls and inject sentinel errors themselves. However different teams might wrap errors differently, reducing consistency across services. By offering a standard error package, we provide a unified, framework endorsed approach that improves developer experience and reduces the burden on individual teams.
-
Enabling application level error detection without needing to couple tightly with internal gofr implementation. Currently, developers often need to resort to brittle string matching or duplicate logic to interpret errors returned from gofr methods.
The goal is to make error taxanomy consistent, clean , avoid duplication and keep a low surface area of maintainence.
Having standardisation is helpful for sure; however, we should be vary of over generalisation. Error interfaces should be defined at the store, service and delivery layer separately so that they may serve the requisite purpose of the respective layer. The interfaces could also support conversion to other layer, i.e. from DBError to ServiceError and from ServiceError to HTTPError/ GrpcError, etc.
Layered error packages introduce tight coupling and redundancy. Understanding and managing layered errors across packages is more complex. It leads to duplicated concepts - e.g., RecordNotFoundError, InvalidIdentifierError, NotFoundError which are all representing the same failure from different perspectives. This violates the DRY (Don't Repeat Yourself) principle and creates semantic clutter for developers.
Besides, layering makes backtracking harder and debugging becomes complicated. The solution isn't to fragment the design into multiple packages but allow flexibility without losing governance control.
It leads to duplicated concepts - e.g., RecordNotFoundError, InvalidIdentifierError, NotFoundError which are all representing the same failure from different perspectives.
While they are similar errors from different perspectives, the behaviour/handling of them would be decided by the respective layer.
layering makes backtracking harder and debugging becomes complicated.
I'd disagree - layering helps define boundaries to limit the scope of debugging. A single entity which exists throughout the application appears very useful at first but can become a management nightmare. A common pattern in java is to let the exception bubble up to the top most layer and get handled there. This of course leads to high code complexity at a single place. I see our implementation proposal similar to the java style.
Origin of the error should decide what level of visibility is to be provided to the caller. Example: Service layer should not care about the underlying reason for an error returned by the store layer, unless the store layer wants to expose it such that the service layer can act on it.
Can you share an example explaining the nature of layering expected here ? Helps provide more insights into it.
Can you share an example explaining the nature of layering expected here ? Helps provide more insights into it.
Each layer (store, service, delivery) would have it's own error interface and corresponding errors (implementing the respective interfaces).
@akshat-kumar-singhal Is this something like the following you had in mind ? If not, feel free to make updates
type StoreError interface { Code() string SubCode() string WithStatusCode(string) ErrorSchema WithMeta(map[string]any) ErrorSchema }
type ServiceError interface { Code() string SubCode() string WithStatusCode(string) ErrorSchema WithMeta(map[string]any) ErrorSchema }
type HandlerError interface { Code() string SubCode() string WithStatusCode(string) ErrorSchema WithMeta(map[string]any) ErrorSchema }
something like this..
type StoreError interface {
error
getServiceError() ServiceError
getRootCause() string
}
type ServiceError interface {
error
getHTTPError() HTTPError
getGrpcError() GrpcError
getRootCause() string
}
type HTTPError interface {
error
getRootCause() string
getStatusCode() int
getErrorMessage() string
}