AnnouncementsIntroducing Arkoren

    Menu

    Error Handling

    Turn returned errors into client-safe Problem Details and preserve causes for diagnostics.

    What's on this page
    1. Distinguish router-selected statuses from explicit problems and custom error renderers.
    2. Define stable client problems and map domain failures without exposing diagnostic causes.
    3. Understand negotiated error output, panic recovery, and failures after commitment.
    On this page

    An Arkoren handler returns a failure instead of formatting an error response at each call site. While the final response is pending, the router renders the error, normally as Problem Details. Keep fallible work before the response helper: after commitment, returning an error reports the failure but cannot replace output already sent.

    This applies to error-returning handlers, including router-generated 404 and 405 problems. Adapted net/http handlers write their own errors. Import net/http, errors, and arkoren.dev/foundation/problem for the examples below.

    Plain Errors

    You can return an ordinary service error unchanged. The router does not send its text to the client, since diagnostics can disclose data or implementation details. Without an error-owned renderer, status selection follows this order:

    1. An error in the chain implementing HTTPStatus() int supplies its status.
    2. Otherwise, a finished request context selects 499, even for its deadline.
    3. Otherwise, an error matching context.DeadlineExceeded selects 504.
    4. All other errors select 500.

    499 is a nonstandard classification; a disconnected client might receive no response. An error implementing both error and http.Handler instead renders itself before commitment. That implementation owns negotiation and client-safe output; it is not wrapped in another generated problem. problem.Details implements both rendering and status interfaces.

    Raw validation errors have no HTTP meaning by themselves. Use request.ValidationProblem or the mapping below to convert violations to 422; do not map infrastructure failures into field advice. See Classify Failures.

    Return Problems

    For a failure the client can act on, return a problem with an explicit safe explanation. Inside an invoice handler with a loaded invoice:

    if invoice.Paid {
    	return problem.Conflict().Derive(func(details *problem.Builder) {
    		details.Detail("A paid invoice cannot be edited.")
    	})
    }

    The status helper supplies 409 Conflict. Derive produces a modified value without changing the template. The response includes type, title, status, and your detail; rendering fills an absent instance from the request path. Other status helpers include BadRequest, NotFound, Forbidden, and UnprocessableContent. Unauthorized takes the authentication challenge string.

    If another failure caused this result, retain it as a diagnostic cause, not as the client explanation. Here err is an existing application error:

    return problem.Conflict().Derive(func(details *problem.Builder) {
    	details.Detail("The invoice changed. Reload it before editing.")
    	details.Error(err)
    })

    The cause is excluded from the response but remains reachable through errors.Is and errors.As. Error(nil) clears a cause. Problem Error() text can include that cause, so it belongs in restricted server diagnostics, not in another response’s detail.

    Define Stable Problems

    If clients distinguish an application failure, define its identity once:

    var ErrInvoiceClosed = problem.New(func(details *problem.Builder) {
    	details.Type("https://api.example.com/problems/invoice-closed")
    	details.Title("Invoice closed")
    	details.Status(http.StatusConflict)
    })

    Return ErrInvoiceClosed or derive occurrence-specific details from it. Clients can branch on type rather than parsing a sentence. The type must be a unique URI reference; it need not resolve to a page. Arkoren’s own failures use stable tag:arkoren.dev,... identifiers and can be remapped the same way as domain errors.

    errors.Is(err, ErrInvoiceClosed) compares stored type, status, and title. Detail, extensions, headers, and cause do not alter this identity; editing an identity member does. Cause traversal remains independent. For errors.As, a value problem matches problem.Details, while a pointer matches *problem.Details, following ordinary Go rules.

    New defaults missing status to 500, type to about:blank, and an empty title to the final status phrase. Derive inherits stored members: changing status alone does not reset an existing title. Callbacks run directly in order; omitting callbacks makes no edits, but a supplied nil callback panics. Retaining a builder cannot alter the published value. Outer extension maps and header slices are copied; nested mutable values remain shared.

    Map Domain Errors

    Central mapping keeps HTTP choices out of domain services. With an existing router routes and application sentinel ErrInvoiceVersion, import arkoren.dev/core/http/middleware/errormap and arkoren.dev/core/http/request:

    routes.Use(errormap.Middleware(
    	errormap.Map(ErrInvoiceVersion, problem.Conflict()),
    	errormap.Func(request.ValidationProblem),
    ))

    Register this before routes and before middleware whose errors it must see. Map uses errors.Is and attaches the original error as the replacement’s cause. Func performs a custom conversion and owns cause preservation itself; ValidationProblem already retains the cause and leaves non-validation errors unchanged. Request Parse* and Validate* helpers perform that conversion already.

    Mappings try the original error in order; the first replacement wins. Return nil to decline reliably. Returning the unchanged input also declines, but separately constructed equal value errors can be indistinguishable. Do not mutate inputs, and use pointer errors when replacement identity matters. Matching through errors.Is alone does not make a derived replacement unchanged. The ordered slice is copied at construction; functions and captures stay shared.

    A mapper cannot rewrite a response already rendered by a native middleware adapter. Put a mapper inside that adapter if it must choose the rendered HTTP result; it can then see only failures from layers inside it.

    Add Client Metadata

    Use extensions for small, client-safe fields, not arbitrary diagnostic objects:

    return ErrInvoiceClosed.Derive(func(details *problem.Builder) {
    	details.Extension("invoice_id", invoice.ID)
    	details.Header("Cache-Control", "no-store")
    })

    Extensions appear at the top level of JSON. Header copies the supplied values; passing no values removes that header. Accessors such as Extension(name) report presence separately from its value; Extensions() copies the outer map.

    For metadata applying to any problem from a request, the router supplies a request-local response context. With a client-safe application requestID:

    if metadata, ok := problem.FromContext(r.Context()); ok {
    	metadata.SetProperty("request_id", requestID)
    }

    Explicit problem extensions win over context properties; context properties cannot replace standard members. Context methods synchronize outer maps and header slices, and rendering snapshots them. Nested maps and slices still need caller coordination during encoding. Keep the context as a pointer: it contains a mutex and must not be copied after use.

    Negotiate Error Output

    The renderer prefers application/problem+json, then application/json, then text/plain; charset=utf-8. Missing or wholly invalid Accept uses that order. A valid header excluding all supported representations produces 406 without the original problem representation.

    For each representation, exact ranges outrank type wildcards, which outrank */*; more matching parameters then outrank fewer, followed by quality. A selected q=0 excludes that representation even if a broader range permits it. Across representations, higher quality wins, then matching-header order, then server preference. Invalid ranges are ignored individually. An omitted q means 1; explicit weights must be 0 or 1, optionally with up to three fractional digits, all zeros after 1..

    Parameters must match what is emitted. Text matches UTF-8 charset case-insensitively, not another charset; other parameter values match exactly. The JSON representations declare no parameters, so a required charset or profile does not match them. For example, text/plain;charset=UTF-8;q=0, text/plain;q=1 still excludes UTF-8 text.

    The renderer owns Content-Type. Problem headers replace context headers of the same name; afterward Vary gains Accept unless it already contains that token case-insensitively or contains *. Other surviving tokens remain, including on 406 responses.

    HTTP rendering completes missing standard members on a copy and changes stored statuses outside 100–999 to 500 in that copy only. Stored accessors, plain JSON, matching identity, and cause remain unchanged. Plain JSON omits an absent status; decoding preserves absence as zero, replaces the destination only on success, and clears previous causes and headers. Defaulted fills missing members but does not validate a nonzero status.

    Encoding finishes before commitment. If JSON encoding fails, text is used only when acceptable under the same negotiation rules; otherwise the result is 406. A body-write failure stops rendering, not a second representation attempt.

    Panics

    Install recovery before registering the handlers and middleware it must protect. Import arkoren.dev/core/http/router:

    routes.Use(router.Recover())

    Before output starts, an ordinary panic becomes an error for normal rendering, usually a generic 500. RecoverBuilder.Handler supplies a custom conversion; Handler(nil) chooses the default. A converter returning nil still produces an error matching router.ErrRecoverUnexpected, never success.

    RecoverBuilder.Events publishes router.PanicRecovered through your event bus. Its error and stack contain sensitive diagnostics. Restrict listeners and sanitize forwarded reports. Recovered errors expose Stack() []byte through errors.As; each call copies the stack, separately from the event’s storage. Listeners of the same event share its bytes, so copy before mutating. Copying does not redact data. http.ErrAbortHandler is re-raised without a recovery event.

    Committed Failures

    A final status, body write, or flush commits the response. Informational 1xx except 101 does not. A successful hijack also prevents further HTTP rendering; a failed hijack does not itself commit. Use the supplied writer to preserve commitment tracking.

    After commitment, ordinary returned errors still reach outer middleware and after-response hooks, but no replacement problem is appended. A recovered panic after earlier output instead raises http.ErrAbortHandler so net/http can abort the response. This is distinct from a native adapter rendering a recovered error before any earlier output: that newly generated problem is not mistaken for output preceding the panic. Wrapping such an error retains errors.Is and errors.As; a new panic is not handled merely because its cause contains an older rendered recovery.

    Hijacked connections remain yours to close and drain. Recovery does not return ownership to the server. See Streaming Responses for cooperative producers, write deadlines, and live-output failures.

    For all constructors and contracts, see the problem, router, and errormap API references.

    Open a bulk documentation archive?

    This file contains every guide in its scope, not API declarations. Loading it all into an agent can exhaust its context window and increase processing time and cost. For coding tasks, start with the index and read only the relevant pages.

    Use the full archive for offline indexing, bulk processing, or deliberate large-context use.

    Browse the index instead
    Open archive