Overriding Error Serialization

Learn how to customize error serialization in Goa services, including handling validation errors and matching organization standards.

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
  • 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

  1. 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"
        }
    }
    
  2. 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
        }
    }
    
  3. 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",
        }
    }
    
  4. 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