Producing and Consuming Errors

Guide to generating and handling errors in Goa services, including using generated helper functions and handling errors on the client side.

Producing and consuming errors are essential aspects of error handling in Goa-based services. This section details how to generate errors within your service implementations and how to handle these errors on the client side effectively.

Producing Errors

Using Generated Helper Functions

Goa generates helper functions for defined errors, simplifying the process of creating standardized error responses. These helper functions ensure that errors are consistent and properly formatted according to the service design. The helper functions are named Make<ErrorName> where <ErrorName> is the name of the error as defined in the DSL. They initialize the error fields based on the service design (e.g. wh ether the error is a timeout, temporary, etc.).

Given the following service design:

var _ = Service("divider", func() {
    Method("IntegralDivide", func() {
        Payload(IntOperands)
        Result(Int)
        Error("DivByZero", ErrorResult, "Divisor cannot be zero")
        Error("HasRemainder", ErrorResult, "Remainder is not zero")
        HTTP(func() {
            POST("/divide")
            Response(StatusOK)
            Response("DivByZero", StatusBadRequest)
            Response("HasRemainder", StatusUnprocessableEntity)
        })
    })
})

 var IntOperands = Type("IntOperands", func() {
    Attribute("dividend", Int, "Dividend")
    Attribute("divisor", Int, "Divisor")
    Required("dividend", "divisor")
 })

Example Implementation:

//...
func (s *dividerSvc) IntegralDivide(ctx context.Context, p *divider.IntOperands) (int, error) {
    if p.Divisor == 0 {
        return 0, gendivider.MakeDivByZero(fmt.Errorf("divisor cannot be zero"))
    }
    if p.Dividend%p.Divisor != 0 {
        return 0, gendivider.MakeHasRemainder(fmt.Errorf("remainder is %d", p.Dividend%p.Divisor))
    }
    return p.Dividend / p.Divisor, nil
}

In this example:

  • The gendivider package is generated by Goa (under gen/divider).
  • The MakeDivByZero function creates a standardized DivByZero error.
  • The MakeHasRemainder function creates a standardized HasRemainder error.

These helper functions handle the initialization of error fields based on the service design, ensuring that the errors are correctly serialized and mapped to transport-specific status codes (400 for DivByZero and 422 for HasRemainder in this example).

Using Custom Error Types

For more complex error scenarios, you might need to define custom error types. Unlike the default ErrorResult, custom error types allow you to include additional contextual information relevant to the error.

Given the following service design:

var _ = Service("divider", func() {
    Method("IntegralDivide", func() {
        Payload(IntOperands)
        Result(Int)
        Error("DivByZero", DivByZero, "Divisor cannot be zero")
        HTTP(func() {
            POST("/divide")
            Response(StatusOK)
            Response("DivByZero", StatusBadRequest)
        })
    })
})

var DivByZero = Type("DivByZero", func() {
    Description("DivByZero is the error returned when using value 0 as divisor.")
    Field(1, "name", String, "Error name", func() {
        Meta("struct:error:name")
    })
    Field(2, "message", String, "Error message for division by zero.")
    Field(3, "dividend", Int, "Dividend that was used in the operation.")
    Required("name", "message", "dividend")
})

Example Implementation:

func (s *dividerSvc) IntegralDivide(ctx context.Context, p *divider.IntOperands) (int, error) {
    if p.Divisor == 0 {
        return 0, &gendivider.DivByZero{Name: "DivByZero", Message: "divisor cannot be zero", Dividend: p.Dividend}
    }
    // Additional logic...
}

In this example:

  • The DivByZero struct is a custom error type defined in the service design.
  • By returning an instance of DivByZero, you can provide custom detailed error information.
  • Note: When using custom error types, ensure that your error struct includes an attribute with Meta("struct:error:name") to allow Goa to correctly map the error. This attribute must be set with the name of the error as defined in the service design.

Consuming Errors

Handling errors on the client side is as important as producing them on the server. Proper error handling ensures that clients can respond appropriately to different error scenarios.

Handling Default Errors

When using the default ErrorResult type, client-side errors are instances of goa.ServiceError. You can check the error type and handle it based on the error name.

Example:

res, err := client.Divide(ctx, payload)
if err != nil {
    if serr, ok := err.(*goa.ServiceError); ok {
        switch serr.Name {
        case "HasRemainder":
            // Handle has remainder error
        case "DivByZero":
            // Handle division by zero error
        default:
            // Handle unknown errors
        }
    }
}

Handling Custom Errors

When using custom error types, client-side errors are instances of the corresponding generated Go structs. You can type assert the error to the specific custom type and handle it accordingly.

Example:

res, err := client.Divide(ctx, payload)
if err != nil {
    if dbz, ok := err.(*gendivider.DivByZero); ok {
        // Handle division by zero error
    }
}

Summary

By effectively producing and consuming errors, you ensure that your Goa-based services communicate failures clearly and consistently. Utilizing generated helper functions for standard errors and custom error types for more complex scenarios allows for flexible and robust error handling. Proper client-side error handling further enhances the reliability and usability of your APIs, providing meaningful feedback to users and enabling appropriate corrective actions.

Testing Error Handling

Testing error handling requires careful validation of error conditions and types. Here’s how to effectively test error handling using Clue’s mock package:

// Import Clue's mock package
import (
    "github.com/goadesign/clue/mock"
)

// Mock implementation using Clue's mock package
// This shows how to properly structure a mock using Clue
type mockDividerService struct {
    *mock.Mock // Embed Clue's Mock type
}

// IntegralDivide implements the mock using Clue's Next pattern
func (m *mockDividerService) IntegralDivide(ctx context.Context, p *divider.IntOperands) (int, error) {
    if f := m.Next("IntegralDivide"); f != nil {
        return f.(func(context.Context, *divider.IntOperands) (int, error))(ctx, p)
    }
    return 0, errors.New("unexpected call to IntegralDivide")
}

func TestIntegralDivide(t *testing.T) {
    // Create mock service using Clue's mock package
    // This demonstrates Clue's powerful mocking capabilities for testing
    svc := &mockDividerService{mock.New()}
    
    tests := []struct {
        name     string
        setup    func(*mockDividerService)
        dividend int
        divisor  int
        wantErr  string
    }{
        {
            name: "division by zero",
            setup: func(m *mockDividerService) {
                m.Set("IntegralDivide", func(ctx context.Context, p *divider.IntOperands) (int, error) {
                    if p.Divisor == 0 {
                        return 0, gendivider.MakeDivByZero(fmt.Errorf("divisor cannot be zero"))
                    }
                    return p.Dividend / p.Divisor, nil
                })
            },
            dividend: 10,
            divisor:  0,
            wantErr:  "divisor cannot be zero",
        },
        {
            name: "has remainder",
            setup: func(m *mockDividerService) {
                m.Set("IntegralDivide", func(ctx context.Context, p *divider.IntOperands) (int, error) {
                    if p.Dividend%p.Divisor != 0 {
                        return 0, gendivider.MakeHasRemainder(fmt.Errorf("remainder is %d", p.Dividend%p.Divisor))
                    }
                    return p.Dividend / p.Divisor, nil
                })
            },
            dividend: 10,
            divisor:  3,
            wantErr:  "remainder is 1",
        },
        {
            name: "successful division",
            setup: func(m *mockDividerService) {
                m.Set("IntegralDivide", func(ctx context.Context, p *divider.IntOperands) (int, error) {
                    return p.Dividend / p.Divisor, nil
                })
            },
            dividend: 10,
            divisor:  2,
            wantErr:  "",
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            // Create fresh mock for each test
            mock := &mockDividerService{mock.New()}
            if tt.setup != nil {
                tt.setup(mock)
            }
            
            // Create payload
            p := &divider.IntOperands{
                Dividend: tt.dividend,
                Divisor:  tt.divisor,
            }
            
            // Call service
            result, err := mock.IntegralDivide(context.Background(), p)
            
            // Verify error behavior
            if tt.wantErr != "" {
                if err == nil {
                    t.Errorf("expected error containing %q, got nil", tt.wantErr)
                } else if !strings.Contains(err.Error(), tt.wantErr) {
                    t.Errorf("expected error containing %q, got %q", tt.wantErr, err.Error())
                }
            } else if err != nil {
                t.Errorf("unexpected error: %v", err)
            }
            
            // For successful cases, verify the result
            if tt.wantErr == "" && result != tt.dividend/tt.divisor {
                t.Errorf("got result %d, want %d", result, tt.dividend/tt.divisor)
            }
            
            // Verify all expected calls were made
            if mock.HasMore() {
                t.Error("not all expected operations were performed")
            }
        })
    }
}