Overriding Error Serialization
This guide explains how to customize the way errors are serialized in Goa services. Error serialization is the process of converting error objects into a format that can be transmitted over HTTP or gRPC to clients. This is especially important for validation errors since they are automatically generated by Goa using a specific error type and cannot be customized at creation time - only their serialization can be controlled.
Overview
When an error occurs in your Goa service, it needs to be converted into a format that clients can
understand. The most common case where you need custom error formatting is for validation errors,
which are automatically generated by Goa and always use the ServiceError type. You cannot change
how these errors are created, but you can control how they are formatted in the response.
Common scenarios where custom error formatting is needed:
Match your organization’s error format standards
- Your organization might have specific requirements for error responses
- You might need to match existing APIs in your ecosystem
- You might want to include additional fields specific to your use case
Format validation errors consistently
- Handle Goa’s built-in validation errors (
ServiceError) - Present validation errors in a user-friendly format
- Include field-specific validation details
- Handle Goa’s built-in validation errors (
Provide custom error responses for specific error types
- Different errors might require different formats
- Some errors might need additional context or details
- You might want to handle validation errors differently from business logic errors
Default Error Response
Goa uses the ServiceError type internally for validation and other built-in
errors. This type includes several important fields:
// ServiceError is used by Goa for validation and other built-in errors
type ServiceError struct {
Name string // Error name (e.g., "missing_field")
ID string // Unique error ID
Field *string // Name of the field that caused the error when relevant
Message string // Human-readable error message
Timeout bool // Is this a timeout error?
Temporary bool // Is this a temporary error?
Fault bool // Is this a server fault?
}
By default, this gets serialized to JSON responses that look like this:
{
"name": "missing_field",
"id": "abc123",
"message": "email is missing from request body",
"field": "email"
}
Custom Error Formatter
To override the default error serialization, you need to provide a custom error formatter when creating the HTTP server.
The formatter must return a type that implements the Statuser interface, which
requires a StatusCode() method. This method determines the HTTP status code
that will be used in the response.
Here’s a detailed breakdown of how to implement custom error formatting:
// 1. Define your custom error response type
// This type determines the JSON structure of your error responses
type CustomErrorResponse struct {
// A machine-readable error code
Code string `json:"code"`
Message string `json:"message"`
Details map[string]string `json:"details,omitempty"`
}
// 2. Implement the Statuser interface
// This tells Goa what HTTP status code to use
func (r *CustomErrorResponse) StatusCode() int {
// You can implement complex logic here to determine the appropriate status code
switch r.Code {
case "VALIDATION_ERROR":
return http.StatusBadRequest
case "NOT_FOUND":
return http.StatusNotFound
default:
return http.StatusInternalServerError
}
}
// 3. Create a formatter function
// This function converts any error into your custom format
func customErrorFormatter(ctx context.Context, err error) goahttp.Statuser {
// Handle Goa's built-in ServiceError type (used for validation errors)
if serr, ok := err.(*goa.ServiceError); ok {
switch serr.Name {
// Common validation error cases
case goa.MissingField:
return &CustomErrorResponse{
Code: "MISSING_FIELD",
Message: fmt.Sprintf("The field '%s' is required", *serr.Field),
Details: map[string]string{
"field": *serr.Field,
},
}
case goa.InvalidFieldType:
return &CustomErrorResponse{
Code: "INVALID_TYPE",
Message: serr.Message,
Details: map[string]string{
"field": *serr.Field,
},
}
case goa.InvalidFormat:
resp.Details = map[string]string{
"field": *serr.Field,
"format_error": serr.Message,
}
// Handle other validation errors
default:
return &CustomErrorResponse{
Code: "VALIDATION_ERROR",
Message: serr.Message,
Details: map[string]string{
"error_id": serr.ID,
"field": *serr.Field,
},
}
}
}
// Handle other error types
return &CustomErrorResponse{
Code: "INTERNAL_ERROR",
Message: err.Error(),
}
}
// 4. Use the formatter when creating the server
var server *calcsvr.Server
{
// Create your error handler (for unexpected errors)
eh := errorHandler(logger)
// Create the server with your custom formatter
server = calcsvr.New(
endpoints, // Your service endpoints
mux, // The HTTP router
dec, // Request decoder
enc, // Response encoder
eh, // Error handler
customErrorFormatter, // Your custom formatter
)
}
This will produce JSON responses that look like:
{
"code": "MISSING_FIELD",
"message": "The field 'email' is required",
"details": {
"field": "email"
}
}
Best Practices
Consistent Format
- Use a consistent error format across your entire API
- Define a standard structure for all error responses
- Include common fields that are always present
- Document your error format thoroughly
Example of a consistent format:
{ "error": { "code": "VALIDATION_ERROR", "message": "Invalid input provided", "details": { "field": "email", "reason": "invalid_format", "help": "Must be a valid email address" }, "trace_id": "abc-123", "timestamp": "2024-01-20T10:00:00Z" } }Status Codes
- Choose HTTP status codes that accurately reflect the error
- Be consistent in your status code usage
- Document the meaning of each status code
- Consider the standard meanings of HTTP status codes
Common status code usage:
func (r *CustomErrorResponse) StatusCode() int { switch r.Code { case "NOT_FOUND": return http.StatusNotFound // 404 case "VALIDATION_ERROR": return http.StatusBadRequest // 400 case "UNAUTHORIZED": return http.StatusUnauthorized // 401 case "FORBIDDEN": return http.StatusForbidden // 403 case "CONFLICT": return http.StatusConflict // 409 case "INTERNAL_ERROR": return http.StatusInternalServerError // 500 default: return http.StatusInternalServerError } }Security
- Never expose internal system details in errors
- Sanitize all error messages
- Use different error formats for internal/external APIs
- Log detailed errors internally but return safe messages
Example of secure error handling:
func secureErrorFormatter(ctx context.Context, err error) goahttp.Statuser { // Always log the full error details for debugging log.Printf("Error: %+v", err) if serr, ok := err.(*goa.ServiceError); ok { switch serr.Name { // Validation errors are safe to expose as they're user input problems case goa.MissingField, goa.InvalidFieldType, goa.InvalidFormat, goa.InvalidPattern, goa.InvalidRange, goa.InvalidLength: return &CustomErrorResponse{ Code: "VALIDATION_ERROR", Message: serr.Message, Details: map[string]string{ "field": *serr.Field, } } // Be careful with fault errors as they might expose internal details case "internal_error": if serr.Fault { // Log for internal monitoring but return generic message alertMonitoring(serr) return &CustomErrorResponse{ Code: "INTERNAL_ERROR", Message: "An internal error occurred", } } // For temporary errors, indicate retry-ability but not the cause case "service_unavailable": if serr.Temporary { return &CustomErrorResponse{ Code: "SERVICE_UNAVAILABLE", Message: "Service temporarily unavailable", Details: map[string]string{ "retry_after": "30", }, } } } } // For any other errors, return a generic error response // This prevents leaking internal implementation details return &CustomErrorResponse{ Code: "UNEXPECTED_ERROR", Message: "An unexpected error occurred", } }Compatibility
- Maintain backward compatibility when changing formats
- Version your error format if making breaking changes
- Document all error format changes
- Provide migration guides for clients
Example of versioned error formats:
func versionedErrorFormatter(ctx context.Context, err error) goahttp.Statuser { // Check API version from context version := extractAPIVersion(ctx) switch version { case "v1": return formatV1Error(err) case "v2": return formatV2Error(err) default: return formatLatestError(err) } }
Conclusion
Custom error serialization allows you to:
- Customize how validation errors are serialized
- Present errors in a consistent format
- Control error detail exposure
- Handle different error types appropriately
- Provide meaningful error responses to clients