goa icon indicating copy to clipboard operation
goa copied to clipboard

question/bug: Is there a way to use custom errors for Goa's internal errors?

Open gabyx opened this issue 9 months ago • 4 comments

The example here shows how to return a custom error: https://github.com/goadesign/examples/blob/master/error/calc.go#L26 This is good but if one wants to customize the error type for a whole service, Goa still uses its own underlying ErrorResult type which can not be customized at the moment or am I missing something?

Also the client uses ClientError: from goa/v3/http/client.go:

and a decode function in the client/encode_decode.go is generated like the blow extract and any server error due to request decoding (ErrorResult) (e.g. missing field) gets mapped into the default: case which returns a gohttp.ErrInvalidResponse. Is this correct? We defined the errors cm#bad-request and cm#invalid-contract etc.

*Encode/Decode in Client:

func DecodeCreateContractResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) {
	return func(resp *http.Response) (any, error) {
		// redacted .........
		switch resp.StatusCode {
		case http.StatusOK:
			// redacted ...
		case http.StatusBadRequest:
			en := resp.Header.Get("goa-error")
			switch en {
			case "cm#bad-request":
				var (
					body CreateContractCmBadRequestResponseBody
					err  error
				)
				err = decoder(resp).Decode(&body)
				if err != nil {
					return nil, goahttp.ErrDecodingError("cm", "createContract", err)
				}
				err = ValidateCreateContractCmBadRequestResponseBody(&body)
				if err != nil {
					return nil, goahttp.ErrValidationError("cm", "createContract", err)
				}
				return nil, NewCreateContractCmBadRequest(&body)
			case "cm#contract-invalid":
				var (
					body CreateContractCmContractInvalidResponseBody
					err  error
				)
				err = decoder(resp).Decode(&body)
				if err != nil {
					return nil, goahttp.ErrDecodingError("cm", "createContract", err)
				}
				err = ValidateCreateContractCmContractInvalidResponseBody(&body)
				if err != nil {
					return nil, goahttp.ErrValidationError("cm", "createContract", err)
				}
				return nil, NewCreateContractCmContractInvalid(&body)
			default:
				body, _ := io.ReadAll(resp.Body)
				return nil, goahttp.ErrInvalidResponse("cm", "createContract", resp.StatusCode, string(body))
			}

gabyx avatar May 27 '25 15:05 gabyx

Hello,

  • For errors that service endpoints return the Goa DSL makes it possible to specify custom error types (like in your example). If there's a need to default to a different type then one can write a custom DSL function that wraps the existing Error function and sets the type, something like:
func MyCustomError(name, desc string) {
    Error(name, MyCustomErrorType, desc)
}

Where MyCustomErrorType identifies a field as the error name using ErrorName

The service can then use MyCustomError("foo", "bar") instead of Error("foo", MyCustomErrorType, "bar") which might help with consistency.

Client side things should "just work" for service endpoint errors (as per your code snippet above) assuming the error object is valid (passes the validations defined in the error type design). The generated server code sets the "goa-error" header which the client leverages to map to the proper type. However errors that use a custom formatter end up being mapped to a generic error type so validation errors will have a different data type on the client.

The Error Handling section of the docs covers the above.

raphael avatar May 27 '25 18:05 raphael

Thanks for the answer!

We implemented exactly this approach with a formatter

https://gitlab.com/data-custodian/custodian/-/blob/feat/continue-with-testing/components/lib-common/pkg/response/error/goa/formatter.go?ref_type=heads#L46

and I realized that on the client side validation errors get mapped to some generic error which is unfortunate. The response is now, ** with the formatter above**, everywhere the same: for our custom errors and for goa validation errors, but when writting integration tests I can only catch our custom errors but as said the goa validation errors are a generic http.ClientError from goahttp.ErrInvalidResponse

Why is that so, why are these validation errors not properly mapped? Could that be added as a feature maybe, I think strong type safety helps a lot here. AFAIS this behavior is independent of the usage of a custom formatter.

gabyx avatar May 29 '25 18:05 gabyx

Makes sense, the challenge here is that by definition Goa has no control on what the custom formatter produces and thus wouldn't know how to initialize a validation error response from the HTTP response body. To tackle this there would have to be a way to inject user code that would understand the custom format and could do this - this is probably the right long term approach but it might be tricky to do in a way that doesn't break the current contract (the formatter is given to the generated NewServer function and that would have to change so that one could inject the "unformatter" as well).

However it might be possible to emulate the above today by:

  1. Defining a "BadRequest" error for all methods and mapping that to the 400 status in HTTP responses
  2. Implementing a client side HTTP middleware that intercepts the formatted response and replaces the body with one that is compatible with Goa's ErrorResponse

This way the Goa generated code client side will unmarshal the body into a ServiceError

(note: I haven't tried the above but AFAIK it should work - happy to answer questions that might come up)

raphael avatar Jun 05 '25 21:06 raphael

Thanks for the explanation that helps. Jeah it would be nice if NewServ function could take the an ErrorMarshaller interface and the client a ErrorUnmarshaller interface (?).

I guess changing this is a major version upgrade...

I might need to try this middleware approach because it makes testing much easier.

gabyx avatar Jun 08 '25 17:06 gabyx