Error Propagation
This guide explains how errors propagate through different layers of a Goa service, from the business logic to the client.
Overview
Error propagation in Goa follows a clear path:
- Business logic generates an error
- Error is matched to its design definition
- Transport layer transforms the error
- Client receives and interprets the error
Error Flow
1. Business Logic Layer
Errors typically originate in your service implementation:
func (s *paymentService) Process(ctx context.Context, p *payment.ProcessPayload) (*payment.ProcessResult, error) {
// Business logic may return errors in several ways:
// 1. Using generated helper functions (for ErrorResult)
if !hasEnoughFunds(p.Amount) {
return nil, payment.MakeInsufficientFunds(
fmt.Errorf("account balance %d below required amount %d", balance, p.Amount))
}
// 2. Returning custom error types
if err := validateCard(p.Card); err != nil {
return nil, &payment.PaymentError{
Name: "card_expired",
Message: err.Error(),
}
}
// 3. Propagating errors from downstream services
result, err := s.processor.ProcessPayment(ctx, p)
if err != nil {
// Wrap external errors in your domain errors
return nil, payment.MakeProcessingFailed(fmt.Errorf("payment processor error: %w", err))
}
return result, nil
}
2. Error Matching
The Goa runtime matches returned errors to their design definitions:
var _ = Service("payment", func() {
// Errors defined here are matched by name
Error("insufficient_funds")
Error("card_expired")
Error("processing_failed", func() {
// Properties influence error handling
Temporary()
Fault()
})
})
The matching process:
- For
ErrorResult
: Uses the error name from the generatedMakeXXX
function - For custom types: Uses the field marked with
struct:error:name
- For unknown errors: Treated as internal server errors
3. Transport Layer
Once matched, errors are transformed according to transport-specific rules:
var _ = Service("payment", func() {
HTTP(func() {
// HTTP mapping rules
Response("insufficient_funds", StatusPaymentRequired)
Response("card_expired", StatusUnprocessableEntity)
Response("processing_failed", StatusServiceUnavailable)
})
GRPC(func() {
// gRPC mapping rules
Response("insufficient_funds", CodeFailedPrecondition)
Response("card_expired", CodeInvalidArgument)
Response("processing_failed", CodeUnavailable)
})
})
The transport layer:
- Applies the appropriate status code
- Formats the error message and details
- Serializes the response
4. Client Reception
Clients receive strongly-typed errors that match the design:
client := payment.NewClient(endpoint)
result, err := client.Process(ctx, payload)
if err != nil {
switch e := err.(type) {
case *payment.InsufficientFundsError:
// Handle insufficient funds (includes error properties)
if e.Temporary {
return retry(ctx, payload)
}
return promptForTopUp(e.Message)
case *payment.CardExpiredError:
// Handle expired card
return promptForNewCard(e.Message)
case *payment.ProcessingFailedError:
// Handle processing failure
if e.Temporary {
return retryWithBackoff(ctx, payload)
}
return reportSystemError(e)
default:
// Handle unexpected errors
return handleUnknownError(err)
}
}
Best Practices
Error Wrapping
- Wrap external errors in your domain errors
- Preserve root cause using
fmt.Errorf("...%w", err)
- Add context relevant to your domain
Consistent Propagation
- Use generated helper functions when possible
- Maintain error properties through the chain
- Don’t mix error types unnecessarily
Transport Considerations
- Define appropriate status codes for each transport
- Include relevant headers/metadata
- Consider client requirements
Client Experience
- Provide strongly-typed errors
- Include sufficient context for handling
- Document retry strategies
Error Transformation Example
Here’s a complete example of how an error transforms through the system:
// 1. Business Logic (Service Implementation)
if !hasEnoughFunds(amount) {
return nil, payment.MakeInsufficientFunds(
fmt.Errorf("balance %d below required %d", balance, amount))
}
// 2. Error Definition (Design)
var _ = Service("payment", func() {
Error("insufficient_funds", func() {
Description("Account has insufficient funds")
Temporary() // Can retry after top-up
})
})
// 3. Transport Mapping (Design)
HTTP(func() {
Response("insufficient_funds", StatusPaymentRequired)
})
// 4. Client Reception
result, err := client.Process(ctx, payload)
if err != nil {
if e, ok := err.(*payment.InsufficientFundsError); ok {
if e.Temporary {
// Wait for retry-after header duration
time.Sleep(retryAfter)
return retry(ctx, payload)
}
}
}
Conclusion
Goa’s error propagation system ensures that:
- Errors maintain their semantic meaning across layers
- Transport-specific details are handled automatically
- Clients receive strongly-typed, actionable errors
- Error handling remains consistent across your API