Skip to content

Errors

Anvil normalizes errors into sdk.Failure.

Handlers still return ordinary Go error values. The generated route and driver code attaches route context, maps the error through the global pipeline, emits an observer event, and lets the driver convert the failure into a protocol-native response.

For a request handled through the shared edge listener, an error moves through this path:

  1. The request reaches Anvil’s edge listener.
  2. The edge selects HTTP, GraphQL, or gRPC.
  3. The selected driver creates the protocol context.
  4. Generated code binds and validates the request when the route has a request model.
  5. Middleware and the handler run.
  6. Binding, decoding, middleware, handler execution, response encoding, transport work, or panic recovery returns an error.
  7. Anvil builds sdk.ErrorContext.
  8. The error pipeline maps the error to sdk.Failure.
  9. Observers registered with app.OnError receive sdk.ErrorEvent.
  10. The driver maps sdk.Failure to the protocol response.

The application controls domain classification by returning sdk.Failure values through ctx.Errors() or registering custom sdk.ErrorMapper implementations.

Queue drivers use the same mapping pipeline after a broker message has been converted to sdk.QueueMessage. There is no edge listener involved for queue delivery.

type Failure struct {
Status int
Message string
Fields map[string]string
Attrs map[string]any
Cause error
Context ErrorContext
Stack []Frame
Expected bool
}

Field behavior:

  • Status is a standard HTTP status code.
  • Message is the public message. Keep it safe to send to a client.
  • Fields stores request field errors for validation failures and invalid parameters.
  • Attrs stores additional structured metadata for internal observers.
  • Cause preserves the technical error for errors.Is and errors.As.
  • Context identifies where Anvil saw the error.
  • Stack is captured for unexpected internal failures.
  • Expected separates domain/client failures from technical failures.

Failure.Error() intentionally does not print Cause, because database errors, cloud SDK errors, SQL text, or secrets can appear there. When Message is empty, Failure.Error() returns internal server error for status 0 or 500, lower-case HTTP status text for known non-500 statuses, and request failed when Go has no text for that status.

Before observers or drivers see a failure, the pipeline normalizes it:

  • Status codes outside 100..599 become 500.
  • Status 500 always becomes public message internal server error.
  • Status 500 always sets Expected to false.
  • Missing non-500 messages fall back to http.StatusText(status) or request failed.
  • Missing Cause is filled with the original error.
  • Fields, Attrs, and Context.Attrs are initialized to empty maps.
  • Internal failures receive stack frames when none were already supplied.

Anvil does not define its own error-code enum. Use net/http status constants:

return nil, ctx.Errors().Failure(http.StatusUnauthorized, "missing authorization")

HTTP drivers send the status directly. The gRPC driver maps common statuses to matching gRPC codes. The GraphQL driver exposes the status as extensions.status.

Internal failures are normalized to internal server error before they reach clients. Put technical details in the wrapped cause or failure attributes for observers.

type ErrorContext struct {
Protocol string
Controller string
Endpoint string
Method string
Route string
Path string
RequestID string
TraceID string
Phase ErrorPhase
Attrs map[string]any
}

Phases are bounded to avoid high-cardinality observability data:

sdk.ErrorPhaseBind
sdk.ErrorPhaseDecode
sdk.ErrorPhasePolicy
sdk.ErrorPhaseHandler
sdk.ErrorPhaseEncode
sdk.ErrorPhaseTransport
sdk.ErrorPhasePanic

Drivers and generated code fill the protocol, controller, endpoint, method, route, path, and phase when those values are known. Custom mappers can return a failure with RequestID, TraceID, or Attrs; the pipeline merges that context with the driver context before publishing the event.

WebSocket and streaming handlers still use the same bounded phases. For example, a socket handler error is reported as handler phase, and a recovered socket panic is reported as panic phase.

Use ctx.Errors() in handlers and middleware:

if actor == nil {
return nil, ctx.Errors().Failure(http.StatusUnauthorized, "missing authorization")
}
if project.IsNotFound(err) {
return ProjectDTO{}, ctx.Errors().NotFound("project")
}

Expected errors set Expected: true. Error observers can use that flag to avoid sending ordinary client mistakes to Sentry.

Failure(status, message) accepts standard HTTP status codes. Invalid status codes are normalized to 500. A normalized 500 is internal, captures stack frames, and sets Expected: false; valid non-500 statuses are expected failures.

NotFound(resource) returns 404 with message "<resource> not found". An empty resource name becomes "resource".

Prefer declarative tags for field-level checks:

type CreateProjectRequest struct {
Name string `json:"name" validate:"required,min=2,max=80"`
}

Use request-level validation only for cross-field or domain checks:

func (req ScheduleRequest) Validate(ctx sdk.Ctx) error {
if req.StartsAt.After(req.EndsAt) {
return ctx.Errors().Validation().
Field("startsAt", "must be before end").
Err()
}
return nil
}

Validation().Err() returns a 400 Bad Request failure with the accumulated field messages. Empty field names are ignored. Empty messages become invalid value.

InvalidParam(name, cause) also returns a 400 Bad Request failure. The public message is invalid request, the field entry is name: invalid value, and cause is kept for observers. An empty param name becomes param.

Use Wrap when a lower-level dependency returns an unexpected error:

created, err := p.Store.Create(ctx.Context(), input)
if err != nil {
return ProjectDTO{}, ctx.Errors().Wrap(err, "creating project")
}

Wrap produces an internal failure with:

  • Public message: internal server error
  • Cause: fmt.Errorf("creating project: %w", err)
  • Stack frames
  • Generated route context

Observers can inspect event.Error, event.Failure.Cause, and event.Failure.Stack. Clients receive the public failure message, not the technical cause.

If Wrap receives a nil cause, Anvil records a missing cause error. If the operation string is empty, it records the operation as operation.

Register mappers when the application already has a domain error vocabulary:

type DomainMapper struct{}
func (DomainMapper) MapError(ctx sdk.ErrorContext, err error) (sdk.Failure, bool) {
if project.IsArchived(err) {
return sdk.Failure{
Status: http.StatusPreconditionFailed,
Message: "project is archived",
Context: ctx,
Expected: true,
}, true
}
return sdk.Failure{}, false
}
app.ErrorPipeline().Use(DomainMapper{})

Mappers run in registration order. The first mapper that returns true wins. Replace swaps the fallback mapper when the application wants full control over classification.

The fallback mapper treats *sdk.Failure as already classified. Any other Go error becomes an internal failure. Mapper output still goes through the same normalization step, so a mapper cannot accidentally expose a custom 500 message.

When both the driver context and mapper-supplied failure context contain a field, the mapper-supplied field wins. Attribute maps are merged.

Observers see the mapped failure after Anvil has merged route context:

app.OnError(func(ctx context.Context, event sdk.ErrorEvent) {
if event.Expected {
metrics.Count("anvil.expected_error", event.Failure.Status)
return
}
reporter.CaptureException(ctx, event.Error, map[string]any{
"status": event.Failure.Status,
"controller": event.Failure.Context.Controller,
"endpoint": event.Failure.Context.Endpoint,
"phase": event.Failure.Context.Phase,
"route": event.Failure.Context.Route,
})
})

sdk.ErrorEvent contains:

  • Error: the normalized failure cause
  • Failure: the normalized failure
  • Expected: whether this is a domain/client failure
  • Recovered: whether it came from panic recovery

event.Error and event.Failure.Cause come from the same normalized failure. For internal failures created by Wrap, that is the wrapped technical cause. For a custom mapper that returns a failure without a cause, Anvil fills the cause with the original error being mapped. For a ctx.Errors() failure without an explicit cause, the original mapped error is the failure value itself.

Observers run synchronously in registration order. They have no error return. If an observer writes to Sentry, Datadog, a log sink, or another external system, put timeouts and panic recovery inside the observer.

Anvil does not recover observer panics. Treat observers like production request code: keep them short, handle their own failures, and move slow external work behind a queue or buffered reporter when needed.

Handlers and middleware return errors for normal failures. Drivers recover panics at protocol boundaries so one bad request does not stop the process.

First-party driver behavior:

Driver pathRecovered panic behavior
anvil-http/std HTTP routesMapped as 500, phase panic, event.Recovered == true
anvil-http/std WebSocketsMapped as 500, phase panic, event.Recovered == true
anvil-http/fiber HTTP routesMapped as 500, phase panic, event.Recovered == true
anvil-http/fiber WebSocketsMapped as 500, phase panic, event.Recovered == true
anvil-graphql execution and subscriptionsRecovered into a handler-phase error
anvil-grpc unary and streaming handlersRecovered into a handler-phase error and converted to gRPC status
anvil-queue job middleware and handlersRecovered into a handler-phase error and returned to the driver delivery path

event.Recovered is computed from failure.Context.Phase == sdk.ErrorPhasePanic. That means it is true for the HTTP-family panic paths above and false for the drivers that recover panics into handler-phase errors.

A recovered panic fails only the current request, socket, RPC, subscription, or queue delivery. It does not stop the process.