D-0005 — Unified Error Handling: REST, Events, ReqReply¶
Status: FULLY IMPLEMENTED — all 7 phases shipped (
gofmt/go build/go test -count=1 ./.../just checkall clean), including a follow-up consistency-review round that closed several gaps found by cross-checking this document against the actual code:DeadLetter's AsyncAPI channel registration (previously documented as "DECIDED" but not implemented), mqtt v3's Implementations-based security dispatch (previously bypassedevents.SecurityErrorwrapping/ErrorChanneleligibility on its documented primary subscribe workflow), mqtt5 User Property param validation (nowErrorChannel/ErrorPattern/DeadLetter-eligible, closing an asymmetry with REST's own wired header-param validation), plus missing test coverage forObserveErrorResponseFor/HasErrorPatterns/ErrorPatternObserver(core-layer AND real-dispatch) and Topic 7's publish-side exclusion invariant, and the security-disclosure guidancedocs/guides/error-handling.mdnow documents. This document has now been physically moved/renamed todocs/design/d-0005-error-handling.md(previously tracked as a separate, optional follow-up) — all ~66 cross-referencing files across the repo were updated to the new path as part of the same graduation round that performed this move.Scope widened in a later review round (H1):
adapters/nethttpandadapters/chieach have a SEPARATE HTTP dispatch function,handlerFunc[Req, Resp any], backing the port/stream-binding entry pointsIngestAdapter/LatestAdapter/HandlerLatest/PipelineHandler— distinct fromserve.go'sserve, which this document's Topic ¼/5 fixes were originally scoped to ONLY.handlerFuncfully supports declaredErrorPatterns viarest.RouteHandlebut never consulted them at any Category-A dispatch point except the handler's own business error (and even that used the bare, non-observabilityErrorResponseForinstead ofObserveErrorResponseFor). This was closed identically to Topic 1's original fix (a new, non-reflectiontryRespondErrorPatternGeneric[Req,Resp]helper, wired at the same 13 Category-A points), purely additive, with regression tests proving a declaredErrorPatternis now consulted and observed viaIngestAdapter(representative of the sharedhandlerFuncsurface).H2 (same review round):
PublishAdapter.Activate'shandleUpstreamErrorclosure inadapters/mqtt/adapters/mqtt5/adapters/zeromq'sbinding.go(events' publish-side sink adapter, handling errors from an UPSTREAM pipeline stage rather than a publish failure) hand-rolled its ownevents.ErrorChanneldispatch via the barehandle.ErrorResponseFor, instead of reusing each package's own already-correcttryPublishErrorChannelhelper (used by the subscribe side andpublish()'s own internal error paths) — silently skippingstats.ErrorPatternObserver/SpanTaggerobservability on this one path. Fixed by delegating totryPublishErrorChannelin all 3 packages; error-channel replies from this path now always use QoS 0/non-retained (mqtt/mqtt5 only — matching every other error-channel dispatch site; zeromq had no such option to begin with, so its fix was a pure zero-behavior-change dedup).DeadLetterfallback intentionally NOT added here (no raw payload exists for an upstream, pre-publish pipeline error — same scope boundary as G3). All other events/reqreply port adapters (SubscribeAdapter,CallAdapter,ServeAdapter,LatestAdapter) were re-audited and confirmed to delegate straight to already-fully-wired dispatch functions — no further gaps found there. ← Back to Design Documents
Motivation¶
Error handling is one of go-codex's most cross-cutting concerns — every
API boundary (api/rest, api/events, api/reqreply) has independently
grown its own declarative error-handling mechanism over several design
rounds (D-0001, D-0003's Addenda, D-0004's Addendum). REST has always been
the most complete reference relative to events/reqreply's own starting
point — a route/middleware handler error is errors.As-matched against
declared ErrorPatterns, realized via a 3-way ErrorAction
(ErrorRespond/ErrorHandle/ErrorLog), and round-trips all the way to
the client via nethttp.CallWithHandle/DecodeErrorFor — though this
document's own Topic 1 finding shows REST had substantial gaps of its own
before this round (see below).
This document consolidates 7 related but previously-separate threads into
one design surface, so REST's reference workflow is deliberately and
completely applied to api/events/api/reqreply, and so the remaining
gaps (AsyncAPI error-reply modeling, dead-letter handling) get one
coherent design instead of three divergent ones:
- Apply REST's declarative handler-error workflow to reqreply, and to
EVERY failure point uniformly — already substantially true (see
"Phase 0" below) for route/channel handlers and general-purpose
middleware alike; this document is where the FULL picture (middleware
errors + handler errors + client decode) is assembled and any
remaining asymmetries are closed. Confirmed and fully enumerated
this round:
ErrorPattern/ErrorChannelis eligible for ONLY 2 of ~10+ distinct failure points per API today (handler errors + general-purpose middleware Fn errors) — security middleware Fn errors, decode/param-validation failures,MiddlewareInputError/MiddlewareOutputError, and response/merge-field encode failures are ALL currently ineligible, across all 3 APIs (see Topic 1's full enumeration). Codex's own error taxonomy and ports/adapter infrastructure errors are PERMANENTLY excluded by contrast (also Topic 1) — not gaps, structural boundaries. - Ratify how reqreply error replies reach the client — same reply topic as the happy path, or a separate one? (Answered below — same topic, already shipped, now formally ratified as a permanent decision.)
- AsyncAPI's OpenAPI-response-code equivalent — does AsyncAPI 3.0
have a way to document multiple reply "shapes" (success + N error
variants) on ONE operation, the way OpenAPI documents
200/409/422on one endpoint? (Answered below — yes, and today's implementation does not yet use it.) - A declarative dead-letter queue/topic for
api/eventsandapi/reqreply— a genuinely new, currently-nonexistent mechanism, complementary to (not a replacement for)ErrorChannel/ErrorPattern. - Deep observability for declared error patterns — a matched
ErrorPattern/ErrorChannelis a live catalogue of every KNOWN, NAMED failure mode an API declares; today NONE of that is visible tostats.Observeron a successful match, only on the rarer mapper/encode-failure sub-case. (Answered below — a new, optionalstats.ErrorPatternObserverextension, mirroringSecurityObserver's established pattern.) - Client-side ergonomics for a matched pattern — declaring on the
server is one concern; using the decoded, typed payload conveniently
on the CLIENT is a distinct one. Today's client workflow is a manual
errors.As+ type-switch dance. (Sketched below — 3 open alternatives, not yet decided.) - Refine pub/sub specifically: confirm
ErrorChannel/dead-letter apply consistently on the SUBSCRIBER (receiver) side, clarify the PUBLISHER (sender) side correctly stays excluded (same role REST/ reqreply's clients already play), and add a small subscriber-side convenience for the unmatched-fallback case.
Design Goal (North Star)¶
The ErrorPattern/ErrorChannel mechanism is designed to be a single,
extensible enforcement point that does TWO things at once, for EVERY
failure point uniformly (route/channel handler, general-purpose
middleware, security middleware, decode, encode — see Topic 1's full
enumeration):
- Declare the typed error response/reply a caller sees — one
errors.As-matched mapping from domain error type to wire shape, decided ONCE per route/channel, independent of which dispatch step produced the error. - Observe every error that flows through that SAME enforcement
point — matched or unmatched — via
stats.ErrorPatternObserver(Topic 5), with zero additional code anywhere else.
This serves two concrete, load-bearing ends:
- Reduces observability effort in handlers (Topic 5's design goal) —
a handler/middleware author who declares an
ErrorPattern/ErrorChanneland simply returns the domain error gets full observability (hit counts, structured logs, trace tags) for free. They never callobs.RecordSomething(...)themselves, mirroring howRecordRequest/RecordSecurityRejectionalready work today. - Keeps adapters thin (Topic 1's Category A "simplification, not new
adapter complexity" finding) — routing EVERY failure point through the
SAME generic
ErrorResponseFor/DecodeErrorForconsultation, instead of each failure point growing its own special-cased fixed-shape branch, collapses today's ~10+ bespokeif err != nil { errFn(status, err); return }sites per API into ONE repeated pattern, reused everywhere. No adapter needs new awareness of any specific error type (codex's own or otherwise) to gain this —ErrorResponseForis already fully generic, built entirely insideapi/rest/api/events/api/reqreplyfrom whatever type the caller declared.
This is a governing principle for the whole document, not just Topics 1 and 5: any NEW failure point discovered in a future round (in any of the 3 APIs, or a future 4th) should, by default, be wired through this SAME enforcement point — declare-and-observe together, in one place — rather than growing its own bespoke error-response/observability branch. Categories B (codex) and C (ports/adapter infrastructure) remain the explicit, permanent exceptions (see Topic 1) — everywhere else, this is the expected default, not an opt-in.
Phase 0 — Already shipped (folded in from reqreply-error-pattern-client-decode.md)¶
The following was fully designed and implemented in a prior round — summarized here as the foundation this document builds on, rather than re-litigated. Full historical detail remains in D-0004's Addendum (this document has now graduated alongside it, per the "Out of scope" section this doc used to close with before graduation).
reqreply.ErrorPatternResponsegainedCode string— the SAME value used to derive the AsyncAPI reply-error channel's operation ID (ErrorPatternOpt.WithCode/sanitized-type-name default), now ALSO transmitted on the wire (mqtt5:x-error-codeUser Property; zeromq: an extra frame) for the matched-pattern case only.RouteHandle.DecodeErrorFor(code, body)— the client-side counterpart ofErrorResponseFor, mirrorsrest.RouteHandle.DecodeErrorForbut matches byCode(reqreply has no wire status to match against).DuplicateErrorPatternCodeError— 2+ErrorPatterns sharing oneCodeare REJECTED atRegistertime (a stricter design than REST's own accepted, test-locked same-status ambiguity — reqreply had no existing behavior to preserve, so this ambiguity was closed from day one).- New
mqtt5.ErrorPatternResponse/zeromq.ErrorPatternResponseclient types, wrapped in each package's existingCallErrorconvention.
What Phase 0 did NOT address (the remaining scope of this document): AsyncAPI's per-error-type SEPARATE reply channel modeling (still one channel per error code today), and dead-letter handling (not addressed at all).
Breaking Changes Policy¶
This project currently has a single maintainer/user. Breaking changes are explicitly ACCEPTABLE, anywhere in this document, whenever they improve implementation clarity or long-term maintainability — no opt-in flags, transition periods, deprecation cycles, or major-version gating are required by default. This is a deliberate policy decision, stated here prominently so it governs every topic below rather than being re-litigated per topic. Where earlier drafting rounds of this document treated backward compatibility as a hard constraint (most notably: REST's same-status ambiguity, previously left unchanged specifically because it was "test-locked" — see Topic 1's new subsection below), those decisions are NOW explicitly REOPENED under this policy. This does not mean every topic MUST introduce a breaking change — additive-by-default remains good engineering discipline independent of this policy (simpler to reason about, smaller diffs) — it means breaking changes are no longer avoided PURELY to preserve compatibility for its own sake.
Scope decisions¶
| In scope | Out of scope |
|---|---|
api/rest, api/events, api/reqreply — the 3 APIs with a mature declarative ErrorPattern/ErrorChannel mechanism already |
api/mcp/adapters/websocket — both have their own distinct ErrorPattern/ErrorFrame mechanisms, already documented per-feature-page; not folded into this document |
| Ratifying (not changing) reqreply's same-reply-topic behavior | — |
REOPENED under the Breaking Changes Policy: making REST's DecodeErrorFor/Register reject 2+ ErrorPatterns sharing one status, mirroring reqreply's DuplicateErrorPatternCodeError — see Topic 1's new subsection |
Changing REST's OpenAPI rendering — OpenAPI already has native per-status responses; no equivalent gap exists there |
Migrating AsyncAPI reply-error rendering to "multiple messages on one reply channel" (render/asyncapi/v3 + api/reqreply/builder.go) — a breaking spec-shape change, now uncontroversial under the Breaking Changes Policy |
|
A NEW declarative dead-letter mechanism for api/events/api/reqreply, scoped to the UNMATCHED-failure case only (per user decision) |
Extending dead-lettering to matched-but-failed-to-encode cases (deferred — those still silently fall back to the existing plain-text/log path, unchanged) |
Extending ErrorPattern/ErrorChannel eligibility to EVERY Category-A failure point (Topic 1): security middleware Fn errors, decode/param-validation failures, MiddlewareInputError/MiddlewareOutputError, and response/merge-field encode failures — matching the ALREADY-eligible handler + general-purpose middleware Fn path |
docs/design/d-0006-protocol-native-capabilities.md's "Handler Disposition" (ack/nack/requeue signaling) — a distinct, complementary, separately-tracked mechanism; cross-referenced, not merged |
| Category B (codex's own error taxonomy) and Category C (ports/adapter infrastructure errors — wiring-time or no-correlated-recipient) — PERMANENT structural exclusions, not gaps; see Topic 1 |
Topic 1 — Applying REST's workflow to reqreply (status check)¶
Guiding principle (stress-tested and BROADENED this round): every
typed, structural error on the response-writing path is
ErrorPattern/ErrorChannel-eligible — not just a route/channel's own
HANDLER and a GENERAL-PURPOSE middleware's Fn, but SECURITY middleware
Fns, body/param DECODE failures, and response/middleware ENCODE failures
alike. A caller declaring a typed error response should not have to
care WHICH dispatch step produced the error — the whole point of the
declarative model is that "this error type maps to this response" is
decided ONCE, independent of where in the pipeline the error originated.
This is a strict superset of the previous round's finding (security
middleware only) — tracing the full adapter dispatch code surfaced that
the SAME gap exists at nearly every OTHER fixed-shape error-response call
site too.
| Layer | REST | events | reqreply | Gap? |
|---|---|---|---|---|
| Middleware input error | MiddlewareInputError{Name,Err} |
MiddlewareInputError{Name,Err} |
MiddlewareInputError{Name,Err} |
None — identical shape, all 3 |
| Middleware business error | MiddlewareError{Name,Err} |
MiddlewareError{Name,Err} |
MiddlewareError{Name,Err} |
None |
| Middleware output error | MiddlewareOutputError{Name,Err} |
MiddlewareOutputError{Name,Err} |
MiddlewareOutputError{Name,Err} |
None (shipped 2 rounds ago) |
| Handler error declaration | ErrorPattern (status + codec) |
ErrorChannel (topic + codec) |
ErrorPattern (code + codec) |
None — same errors.As/first-wins/3-way-action model |
General-purpose middleware Fn error → ErrorPattern-eligible? |
Yes — runMiddlewareHandlersReflect's fn-error branch consults ErrorResponseFor before falling back to MiddlewareError (documented as "D2") |
Confirmed same via dispatchSubscribeMiddlewareHandlers/dispatchPublishMiddlewareHandlers |
Confirmed same via dispatchServerMiddlewareHandlers |
None — already uniform |
Security middleware Fn error → ErrorPattern-eligible? |
No — bypasses ErrorResponseFor entirely |
No — same | No — same | Confirmed gap — see enumeration below |
Body/param decode + validation failure → ErrorPattern-eligible? |
No — bypasses ErrorResponseFor entirely |
No — same | No — same | Confirmed gap — see enumeration below |
Middleware DecodeIn/EncodeOut failure → ErrorPattern-eligible? |
No — already wrapped in MiddlewareInputError/MiddlewareOutputError, but neither is ever passed to ErrorResponseFor |
No — same | No — same | Confirmed gap — see enumeration below |
Response body/merge-field encode failure → ErrorPattern-eligible? |
No — bypasses ErrorResponseFor entirely |
No — same | No — same | Confirmed gap — see enumeration below |
| Handler error → client decode | DecodeErrorFor (status) → ErrorPatternResponse |
N/A (no synchronous caller) | DecodeErrorFor (code) → ErrorPatternResponse |
None (Phase 0) |
| AsyncAPI/OpenAPI modeling | Native per-status responses |
One dedicated topic per ErrorChannel (already appropriate — no "reply" concept in pub/sub) |
Separate reply channel per error code | Gap — Topic 3 |
| Dead-letter / catch-all | N/A (HTTP always has SOME response) | None | None | Gap — Topic 4 |
Full failure-point enumeration — 3 categories¶
Every error that CAN occur while producing a response, across all 3 APIs, falls into exactly one of 3 categories. Category A is this document's newly-broadened scope; B and C are PERMANENT, structural exclusions, not gaps.
Category A — in scope: every failure point on an already-dispatched, correlated request/message¶
These all occur AFTER a request/message has been matched to a specific
route/channel and a live response/reply correlation exists (an HTTP
ResponseWriter, an MQTT reply topic, a pub/sub error channel) — exactly
the precondition ErrorPattern/ErrorChannel needs to apply.
REST (adapters/nethttp/serve.go; adapters/chi/serve.go mirrors it
exactly — SSE is EXCLUDED: rest.SSERoute.Register/RegisterHandle
already reject ErrorPattern/ErrorStatus RouteOpts outright, via the
already-shipped rest.SSEErrorPatternUnsupportedError — SSE has no
"response" concept to shape a typed error into, so there is nothing for
this document's Category A enumeration to extend there):
| # | Failure point | Current fixed shape | Currently ErrorPattern-eligible? |
|---|---|---|---|
| 1 | Request body decode (Unmarshal) |
raw error, 400 |
No |
| 2 | Path param validation | rest.PathParamError, 400 |
No |
| 3 | Query param validation | rest.QueryParamError, 400 |
No |
| 4 | Cookie param validation | rest.CookieParamError, 400 |
No |
| 5 | Header param validation | rest.HeaderParamError, 400 |
No |
| 6 | Middleware DecodeIn failure |
rest.MiddlewareInputError, 400 |
No |
| 7 | Middleware Fn business error | rest.MiddlewareError, 400 |
Yes (already shipped) |
| 8 | Handler business error | (caller's own error type), 500 default | Yes (already shipped) |
| 9 | Response header/cookie merge-field encode | raw error, 500 |
No |
| 10 | Middleware EncodeOut failure |
rest.MiddlewareOutputError, 500 |
No |
| 11 | Response body encode (Marshal) |
raw error, 500 |
No |
| — | Security middleware Fn error (carried over from the prior round's finding) | rest.SecurityError, 401 |
No |
Rows 1–6, 9–11, and the security row are ALL newly in scope for this document — 10 of 12 distinct failure points. Only rows 7–8 (already shipped) are currently eligible.
events (adapters/mqtt5/zeromq/mqtt's adapter.go/binding.go,
subscribe side — publish side is structurally simpler, no decode step):
| # | Failure point | Current fixed shape | Currently eligible? |
|---|---|---|---|
| 1 | Payload decode | SubscribeError{Kind: KindDecode}, via OnError |
No |
| 2 | Topic-var merge failure | Same | No |
| 3 | Property-var merge failure (mqtt5 only) | Same | No |
| 4 | Middleware DecodeIn failure |
events.MiddlewareInputError via OnError |
No |
| 5 | Middleware Fn business error (SUBSCRIBE side) | events.MiddlewareError via OnError |
Yes (already shipped) |
| 6 | Handler business error (SUBSCRIBE side) | (caller's own error type) via OnError |
Yes (already shipped) |
| — | Security middleware Fn error (SUBSCRIBE side, carried over) | events.SecurityError via OnError |
No — in scope, see below |
| — | Middleware Fn/EncodeOut failure (PUBLISH side) |
events.MiddlewareError/MiddlewareOutputError, returned directly |
No — correctly excluded, see Topic 7 (publisher plays the "client/sender" role, same as REST/reqreply's clients) |
reqreply (adapters/mqtt5/zeromq's reqreply_transport.go,
server-side dispatch — request-decode side is structurally similar to
events' subscribe side, reply-encode side mirrors REST's response side):
| # | Failure point | Current fixed shape | Currently eligible? |
|---|---|---|---|
| 1 | Request payload decode | plain-text error reply | No |
| 2 | Topic-var/property-var merge failure | Same | No |
| 3 | Middleware DecodeIn failure |
reqreply.MiddlewareInputError, plain-text reply |
No |
| 4 | Middleware Fn business error | reqreply.MiddlewareError |
Yes (already shipped) |
| 5 | Handler business error | (caller's own error type) | Yes (already shipped) |
| 6 | Middleware EncodeOut failure (building the reply) |
reqreply.MiddlewareOutputError, plain-text reply |
No |
| — | Security middleware Fn error (carried over) | reqreply.SecurityError, plain-text reply |
No |
Proposed fix (to be fully speced in a follow-up round — same
mechanism for every row above, not a new one per row): before falling
back to each row's existing fixed shape, the adapter should ALSO consult
ErrorResponseFor/DecodeErrorFor first — mirroring the EXACT fallback
structure ALREADY used by rows 7–8/5–6 (REST/events/reqreply's
already-shipped handler+middleware-Fn rows): ErrorResponseFor first; on
no match, or a mapping failure, fall back to that row's existing fixed
shape UNCHANGED. This is purely additive — routes/channels declaring no
matching ErrorPattern/ErrorChannel for a given row's error type see
ZERO behavior change.
Security consideration — Direct vs. Mapped mode for security-related
patterns (guidance, not a design flaw): today's fixed SecurityError
shape already exposes err.Error() — a FLATTENED STRING — via the
default JSON envelope (confirmed:
errFn(sw, r, http.StatusUnauthorized, rest.SecurityError{Err: err})).
Once security middleware failures become ErrorPattern-eligible, a
caller declaring the pattern in Direct mode (no mapFn, E and B
the SAME type) has the underlying error's ENTIRE STRUCTURED VALUE
serialized via its own codec — not just the flattened string today's
fallback produces. For ORDINARY business errors this is the whole point
(richer, structured data reaching the caller) — but for
SECURITY-RELATED errors specifically, this can be a real footgun:
security error messages are OFTEN deliberately vague on purpose (e.g.
never distinguishing "user not found" from "wrong password," to prevent
account-enumeration attacks), and an internal error type may carry
EXTRA fields (a wrapped credential-store error, internal validation
context, etc.) never intended for external disclosure. Recommendation
for the eventual implementation/docs: when declaring an ErrorPattern
for a security-related error type, prefer Mapped mode with an
explicit mapFn that DELIBERATELY constructs a minimal, safe payload
(e.g. {Code: "unauthorized"}) — never Direct mode reusing an internal
security error type wholesale. This is guidance to document prominently
(a callout in the eventual guide, not a new mechanism or constraint) —
ErrorPattern itself should not gain any new restriction; the risk is
entirely in HOW a caller chooses to declare the pattern, identical in
kind to any other codec-declared struct's information-disclosure
surface, just worth flagging explicitly for THIS specific row given how
routinely security errors are deliberately under-specified elsewhere in
API design.
Why this is a SIMPLIFICATION, not new adapter complexity: the adapter
requires ZERO new awareness of any SPECIFIC error type (codex's own or
otherwise) to implement this — ErrorResponseFor is already a fully
generic, caller-parameterized closure (built via errors.As against
whatever type E the CALLER declared at Register time, entirely inside
api/rest/api/events/api/reqreply). The adapter's job at every one of
the ~10 rows above is IDENTICAL regardless of which row it is: call the
SAME ErrorResponseFor(err) accessor it already calls for rows 7–8,
passing whatever error just occurred. Today's ~10 bespoke
if err != nil { errFn(status, err); return } branches — each currently
hard-coding its own status/shape — collapse into ONE repeated pattern
reused at every failure point, rather than N special-cased ones. This
makes the adapter THINNER, not thicker.
Category B — permanently excluded: codex's own error taxonomy¶
codex.ValidationErrors/TypeMismatchError/ConstraintError/etc. must
NEVER themselves gain awareness of ErrorPattern — codex is imported
BY api/rest/api/events/api/reqreply, never the reverse; giving codex
knowledge of ErrorPattern would invert the dependency graph. This
boundary is permanent, not a gap to close.
Critically — per the "thin adapter" point above — this does NOT mean
the adapter needs any codex-specific awareness either. A caller is
already free TODAY to declare
rest.ErrorPattern[codex.ValidationErrors, MyValidationBody](422, ...) —
ErrorPatternOpt.applyRoute's generic E error type parameter accepts
ANY error type, including a codex one, with zero special-casing anywhere.
The only reason this doesn't already work end-to-end is Category A's
enumeration above: Unmarshal's decode error (which for a decode
failure WOULD be codex.ValidationErrors) is never passed to
ErrorResponseFor at all today. Once Category A's fix lands, a caller
CAN match on codex.ValidationErrors (or any other codex error type)
exactly like any other declared pattern — no new mechanism, no new codex
awareness anywhere, just Category A's existing generic consultation
reaching one more failure point.
Category C — permanently excluded: ports/adapter infrastructure errors¶
ports.PortBindError/PortNoAdapterError/PortNoPipelineError (returned
by Bind/Connect, called ONCE at wiring/startup time — before any
request/message has ever been dispatched, with no route/topic context to
look up declared patterns against) and client-side transport errors
(nethttp.RequestError/RequestBuildError/UnexpectedStatusError,
mqtt5.CallError's KindTimeout/network-layer cases — the CLIENT itself
IS the caller; there is no second, correlated party to route a typed
response TO) are structurally excluded from ErrorPattern. Its entire
premise — "map a business error to a wire response FOR AN ACTIVE,
CORRELATED CALLER" — does not apply to either: infrastructure failures
either predate any correlation existing, or have no correlated recipient
at all. This is not a gap; there is no coherent way to apply the
mechanism here.
Conclusion: Category A (every structural failure point on an already-dispatched, correlated request/message) is a genuine, confirmed, much larger gap than the prior round's security-middleware-only finding — now fully enumerated per API. Categories B and C are permanent, correctly-drawn boundaries, not gaps — codex remains fully layer-agnostic forever, and infrastructure errors correctly keep their own separate observability, with no coherent "correlated response" to route them through in the first place.
REOPENED under the Breaking Changes Policy: REST's same-status ambiguity¶
A prior round of this document documented REST's same-status
ErrorPattern ambiguity as a permanent, accepted caveat: 2+ patterns
sharing one HTTP status make DecodeErrorFor deterministically pick the
FIRST-declared one client-side, regardless of which the server actually
sent — intentional, tested (TestDecodeErrorFor_FirstMatchWins_SameStatus),
and left unchanged specifically because "REST's behavior is test-locked"
— i.e. purely a backward-compatibility concern, not a judgment that the
ambiguity itself was desirable. Under the Breaking Changes Policy
above, that reasoning no longer applies — this is now a genuine
improvement opportunity, not a permanent exclusion.
Proposed fix: mirror reqreply's ALREADY-SHIPPED
DuplicateErrorPatternCodeError exactly — reject 2+ ErrorPatterns
declared on the SAME route sharing one status at Register/
RegisterHandle time, via a new rest.DuplicateErrorStatusError{Route,
Status, FirstType, SecondType} (identical shape to reqreply's own error
type, adapted from Code string to Status int). This makes REST as
strict as reqreply from day one, rather than the two APIs permanently
diverging on this specific behavior for historical reasons alone.
Consequences, now acceptable under the policy:
- TestDecodeErrorFor_FirstMatchWins_SameStatus (the existing test
documenting today's accepted behavior) gets its expected outcome
CHANGED — from "first-declared wins silently" to "rejected at
Register time with DuplicateErrorStatusError." This is the test
actively being updated, not merely a caveat being documented around.
- Any EXISTING route (in this repo's own examples/tests) that
currently declares 2+ ErrorPatterns sharing one status would need
its test/example fixed to use distinct statuses — a quick grep-and-fix
pass, not a design risk.
- docs/features/rest-api.md's "same-status precedence" callout (added
in an earlier round) becomes STALE once this ships — needs updating to
describe the new rejection instead of the old accepted-ambiguity
behavior.
- Symmetric bonus: REST's error-handling guide and reqreply's now read
IDENTICALLY on this point — one fewer cross-API inconsistency for a
reader to track, reinforcing this document's own Design Goal of
uniform behavior across all 3 APIs.
Topic 2 — Same reply topic (ratified, not changed)¶
Decision: reqreply error replies stay on the SAME reply topic/socket as the success reply — this document formally ratifies today's already-shipped behavior as the permanent design, not a transitional state.
Confirmed via the actual runtime code (not just intent): mqtt5's
publishErrorReply/publishHandlerErrorReplyReflect and zeromq's
sendErrorReply/sendHandlerErrorReplyReflect/
sendRouterHandlerErrorReplyReflect ALL publish/send to the exact SAME
responseTopic/socket the success reply would use — distinguished only
by an envelope marker (mqtt5's ContentType: "application/mqtt5-error";
zeromq's "error" status frame), never a separate topic/channel.
This matches how synchronous RPC-over-messaging is done in practice industry-wide:
- gRPC — the status/error code is part of the SAME response envelope/trailers; there is no separate error stream.
- AMQP RPC pattern (
reply-to+correlation-id) — the reply is published to the SAME reply-to queue; success/failure is distinguished by a header or the body's own envelope shape. - NATS request-reply — the reply arrives on the SAME inbox subject (sometimes with a "no responders"/error status header); never a separate subject.
A dedicated error TOPIC only makes sense for fire-and-forget pub/sub,
where there is no synchronous caller waiting on a specific reply address
at all — which is exactly why events.ErrorChannel's separate topic is
independently correct for events (there is no shared "reply address" to
reuse), while reqreply's synchronous, correlated request-reply shape has
a natural "same address" answer that pub/sub structurally lacks. These
are not inconsistent with each other; they are the correct answer for
each transport's actual shape.
No code change for this topic — it is a ratification, documented here
so a future contributor does not "fix" this into a separate-topic design
by analogy with events.ErrorChannel without understanding why the two
differ.
Topic 3 — AsyncAPI reply-error modeling: migrate to multi-message channels¶
Current state¶
api/reqreply/builder.go's per-route registration loop generates a
SEPARATE ChannelItem (with its own channel key, address, and
Subscribe operation) for every declared ErrorPattern/ErrorReplyMeta
— e.g. a route with 2 declared error patterns produces 3 total reply
channels: the success reply channel (computeAddReply at
compute/add/reply) plus 2 error reply channels
(computeAddReplyErrorConflict at compute/add/reply/error/conflict,
etc.), each with its OWN operation and OWN single Message.
AsyncAPI 3.0's native mechanism (confirmed via spec research)¶
AsyncAPI 3.0 supports a channel-level messages object (a map of named,
reusable message definitions) and an operation-level messages array
(references into that map) — this is the direct analogue of OpenAPI's
per-status responses object: ONE channel, ONE operation, MULTIPLE named
message variants. Per spec, when an operation's messages field is
omitted, ALL of the channel's declared messages apply to that operation —
so for reqreply's reply channel (where literally any declared shape,
success or error, might arrive), we do not even need to populate the
operation-level messages array explicitly; we only need the CHANNEL's
messages map to carry every variant.
Confirmed via code that render/asyncapi/v3/document.go's
buildChannelsAndOperations already assembles a messages map per
channel today — but only ever from exactly ONE message (whichever the
channel's Subscribe/Publish operation declares). This needs to become
a LIST.
Proposed API surface¶
// render/asyncapi/v3/document.go
// Operation gains a plural Messages field, alongside the existing
// singular Message (kept for the common single-shape case — zero
// migration needed for every OTHER channel in this codebase that
// declares exactly one message).
type Operation struct {
// ... existing fields unchanged ...
// Message is the operation's sole message, when there is exactly one
// (the common case — unaffected by this change).
Message Message
// Messages, when non-empty, lists MULTIPLE named message variants for
// this operation (e.g. a reply channel's success shape plus N
// declared ErrorPattern shapes) — takes priority over Message when
// both are set. Each Message.Name (or SchemaName, falling back to a
// generated key) becomes the channel's messages map key.
Messages []Message
}
// api/reqreply/builder.go — the per-route registration loop changes from
// "one reply channel per error pattern" to "one reply channel, N messages".
func (b *Server) registerRoute(...) {
replyMessages := []asyncapi.Message{
{Name: "Success", Schema: respSchema, SchemaName: meta.RespSchemaName, Headers: respHeaders},
}
for _, er := range errorReplies {
name := "Error"
if er.Code != "" {
name += capitalise(topicToID(er.Code))
}
replyMessages = append(replyMessages, asyncapi.Message{
Name: name, Schema: er.Schema, SchemaName: er.SchemaName,
})
}
b.docBuilder.AddReplyChannel(replyChannelKey, asyncapi.ChannelItem{
Address: topic + "/reply",
Parameters: params,
Subscribe: &asyncapi.Operation{
OperationID: recvOpID,
Messages: replyMessages, // was: Message: asyncapi.Message{...}
},
})
// The per-error-type errReplyChannelKey/errReplyAddress loop is REMOVED
// entirely — no more separate channels for error variants.
}
Consequences¶
- Breaking spec-shape change: any existing consumer of a
reqreply-produced AsyncAPI document that expects separate<route>ReplyError<Code>channel keys will see them disappear, folded into the single reply channel'smessagesmap instead. This is a behavior change to the GENERATED SPEC, not to any Go API signature — existingreqreply.ErrorPattern/ErrorReplyMetadeclarations need NO changes; only the rendered AsyncAPI document's shape changes. - Simpler, more idiomatic spec output: N error types no longer cost N
extra channels + N extra operations — they become N extra entries in
ONE existing channel's
messagesmap. events.ErrorChannelis unaffected — it correctly uses a genuinely separate topic (Topic 2's reasoning), which has nothing to do with this multi-message mechanism;Operation.Messagesis available toeventstoo (shared renderer), but there is no current use case for it there (an events channel's subscribe operation has exactly one message shape: the domain event itself — there is no "reply" to enumerate variants of).
messages map key derivation — DECIDED this round¶
buildChannelsAndOperations derives the STRING KEY for each entry in a
channel's messages map from its Message.Name, falling back to
SchemaName when Name is empty. On an ultimate collision (two
messages in the same Operation.Messages slice landing on the same
derived key), rendering returns a new render-time error rather than
silently overwriting one entry with the other — a collision at this
point indicates a caller bug, since Topic 1's reopened
DuplicateErrorStatusError (REST) and reqreply's existing
DuplicateErrorPatternCodeError already prevent the realistic
naturally-occurring cases upstream, before this renderer code ever runs.
Topic 4 — Declarative dead-letter queue/topic (events + reqreply)¶
Motivation and scope (per user decision: unmatched-only)¶
Confirmed via repo-wide search: no declarative dead-letter mechanism
exists anywhere in go-codex today. The closest adjacent things are (a)
stream.MapErr/LogOnError — generic, IMPERATIVE, pipeline-level
dead-lettering, not a route/channel DECLARATION, and (b)
docs/design/d-0006-protocol-native-capabilities.md's "Handler Disposition" — a
DISTINCT, NOT YET IMPLEMENTED idea for a handler to signal ack/nack/
requeue outcomes (a per-message disposition signal, not a destination
policy for given-up-on messages — complementary, tracked separately, not
merged into this document).
events.ErrorChannel/reqreply.ErrorPattern are TYPE-MATCHED — they only
fire for a SPECIFIC declared business error type via errors.As. Per the
scope decision, this document's dead-letter mechanism covers ONLY the
genuinely UNMATCHED case: a decode failure before any business error
exists, or a business error whose type matches no declared
ErrorChannel/ErrorPattern. (Matched-but-failed-to-encode cases are
explicitly OUT of scope for this round — they keep today's existing
plain-text/log fallback behavior, unchanged.)
How dead-letter queues actually work in practice (broker-native vs. application-level)¶
A question worth answering explicitly before proposing an API: is a "dead-letter queue" a BROKER feature, or something the application builds? The honest answer is both — depending on the messaging system — and go-codex's current transports (mqtt, mqtt5, zeromq) fall on the side that requires application-level work, which directly shapes this design.
| Mechanism | Who moves the failed message | Configured how | Examples |
|---|---|---|---|
| Broker-native | The BROKER, automatically | Infrastructure/queue config, NOT application code — e.g. a queue argument set once at declare/bind time | RabbitMQ/AMQP's x-dead-letter-exchange queue argument; AWS SQS's redrive policy |
| Application-level | The APPLICATION, explicitly, at runtime | The consumer's own code publishes the failed message to a designated topic — which is just an ORDINARY topic, nothing special about it on the wire | Kafka (no native DLQ concept — a "dead letter topic" is purely a naming convention your code implements); MQTT (v3/v5) — no native dead-letter concept in the protocol at all; ZeroMQ — no broker whatsoever (brokerless by design), so no broker feature could exist even in principle |
Since go-codex's shipped pub/sub adapters are exclusively MQTT-family and ZeroMQ — both squarely in the "application-level" column, with no broker feature to delegate to at all — the design below (declare a destination topic the SAME way a channel is declared; the ADAPTER's own dispatch code explicitly publishes to it on an unmatched failure) is not a simplification or a compromise relative to "real" DLQ semantics — it is the correct, idiomatic implementation for these specific transports, matching exactly how Kafka-based systems already do this.
This distinction becomes directly relevant to a currently-separate
roadmap doc: docs/roadmap/amqp-adapter.md's already-sketched
QueueConfig.Args field explicitly anticipates
x-dead-letter-exchange/x-message-ttl as broker-native queue
arguments — meaning a FUTURE AMQP adapter could realize the exact SAME
declared events.DeadLetter(topic, ...)/reqreply.DeadLetter(topic, ...)
API (this document's proposal, below) via broker-native queue
configuration instead of runtime application code — zero per-message
publish cost, the broker does the work. See docs/design/d-0006-protocol-native-capabilities.md,
§6 "Concrete feature survey," new "AMQP dead-lettering" entry, for the
full analysis of why this is a genuinely NEW category for that document's
own two-part Capability test — a capability whose DECLARATION can be
shared uniformly across every adapter (unlike QoS/User Properties, which
get NO shared declaration at all) even though its REALIZATION diverges
completely by whether the underlying broker has native support.
Proposed API surface¶
// api/events — new ChannelOpt, declared alongside ErrorChannel on the
// SAME NewChannel call.
//
// DeadLetter declares a fallback destination topic for a message that
// could not be processed for ANY reason NOT already covered by a more
// specific declared ErrorChannel — a decode failure, or a business error
// whose type matches no declared ErrorChannel. Fires strictly AFTER every
// declared ErrorChannel has been tried and found no match (or was not
// declared at all) — never instead of a more specific match.
//
// Unlike ErrorChannel's codec-backed TYPED payload, DeadLetter's payload
// is a FIXED, generic envelope — there is no reliable business type to
// encode once processing has failed unmatched:
//
// events.NewChannel[Reading]("sensors/{id}/data", readingCodec,
// events.ErrorChannel[domain.ValidationError, ErrorPayload](...), // specific, tried first
// events.DeadLetter("sensors/dead-letter"), // catch-all fallback
// )
func DeadLetter(topic string, opts ...DeadLetterOpt) ChannelOpt
// DeadLetterEnvelope is the FIXED payload schema DeadLetter publishes —
// the same shape regardless of which channel/route dead-lettered it.
type DeadLetterEnvelope struct {
// SourceTopic is the original channel's topic (or reqreply route's topic).
SourceTopic string
// Payload is the original, UNDECODED raw bytes — decode may itself be
// what failed, so this is never re-encoded from a typed value.
Payload []byte
// Error is the failure's error string (Error(), not a typed value —
// the whole point of DLQ is that no reliable type exists here).
Error string
// Timestamp is when the dead-letter was produced.
Timestamp time.Time
}
// api/reqreply — same shape, RouteOpt instead of ChannelOpt. Complementary
// to (not a replacement for) the synchronous error reply (Topic 2) — the
// caller STILL gets an error reply on the same reply topic regardless;
// DeadLetter ADDITIONALLY preserves a durable record for ops/replay. Both
// fire together on an unmatched failure, not as alternatives.
func DeadLetter(topic string, opts ...DeadLetterOpt) RouteOpt
Design decisions — DECIDED this round¶
DeadLetterOptsurface: mirrorsreqreply.ErrorPatternOpt's fluent pattern exactly —WithCode/WithDescription/WithSchemaName/WithChannelAddress/WithOperationID.DeadLetterDOES generate its own AsyncAPI channel entry, using the FIXEDDeadLetterEnvelopeschema for every declaration (a single reusable message shape, unlikeErrorPattern's per-declarationBtype).- Global default: YES.
DeadLettersupports a global default declared once at theClient/Server/Builderlevel, mirroringGlobalSecurity/Security's established nil-inherit/empty-override precedent exactly: a channel/route with no explicitDeadLetter(...)opt inherits the builder-level default; an explicitDeadLetter(topic, ...)on the route/channel itself overrides it; an explicit opt-out (mirroringSecurity: []route.SecurityRequirement{}'s empty-slice convention) lets a route/channel disable dead-lettering even when a global default exists. - Which failures reach DeadLetter — confirmed via tracing
adapters/mqtt5/adapters/mqtt's actual subscribe dispatch code, in TWO TIERS, mirroring Topic 1's Category A enumeration exactly: - Tier 1 — no business error exists yet (decode-class), ALWAYS
dead-lettered when declared, unconditionally: payload decode
failure, topic-var extraction/mismatch failure, topic-var merge
failure, property-var merge failure, middleware
DecodeInfailure. There is nothing for these to "not match" against — noErrorPattern/ErrorChannelcould ever apply before a business error exists — so DeadLetter is the ONLY declarative fallback tier available for this class, and fires every time one is declared. - Tier 2 — a business error already exists (Fn/handler-class), only
dead-lettered AFTER
ErrorResponseForfinds no match: middleware Fn error, route/channel handler error, security middleware Fn error (once Topic 1's fix makes this last oneErrorPattern-eligible). This preserves the fallback-tier ordering Topic ¼ already establish: a more specific declaredErrorPattern/ErrorChannelalways wins first; DeadLetter is strictly the LAST resort. "No match" means a genuine TYPE non-match only (session-review round-3 clarification, G4) — a declaredErrorChannelthat DOES type-match viaerrors.Asbut resolves to a non-ErrorRespondaction (ErrorHandle/ErrorLog) is STILL a match for this purpose, and must NOT also reachDeadLetter; only a pattern whoseerrors.Ascheck itself fails (or a matched-but-failed-to-map/ encode pattern, per the existing out-of-scope carve-out below) falls through to Tier 2's DeadLetter consultation. - "Handler panic" dropped from scope entirely — confirmed via
grep -rn "recover()"that onlyadapters/nethttp/adapters/chi(REST) have any panic-recovery mechanism at all;mqtt/mqtt5/zeromq's events and reqreply dispatch code has none, and adding one is out of scope for this document (REST is out of DeadLetter's scope entirely, per this document's own Motivation). - Observer integration:
stats.ReportErrors(obs, "dead_letter", err)— a new location string, following the established"middleware:in"/"middleware:out"/"error_pattern"/"error_channel"convention, fired whenever a message is actually dead-lettered. mqtt(v3): YES, included from day one. Confirmed via code (adapters/mqtt/adapter.go's subscribe dispatch) that mqtt v3 has an IDENTICAL decode → topic-var → security → middleware → handler dispatch shape to mqtt5/zeromq (it simply lacks a User-Property/ property-var stage) — the dead-letter envelope itself needs no protocol-specific metadata (it's a plain published message), so there is no blocker.- Failed publish (never reached the broker): YES, dead-letterable,
reusing the SAME
DeadLetterEnvelope/topic mechanism — NOT a separate "retry queue" concept. A publish-side middleware Fn/encode failure, or a broker-level rejection, ADDITIONALLY gets published to the SAME declared dead-letter destination a subscribe-side failure would use, alongside (not instead of) the synchronous Go error the caller already receives fromPublish(...)(Topic 7) — giving an operator a durable, replayable record of outbound failures without requiring the caller to build their own retry-queue plumbing. This is DELIBERATELY narrower than the subscribe side's full Category-A coverage — scoped specifically to middleware Fn/encode failures and broker-level rejection (a message that entered dispatch and then failed), NOT toBuildTopic/client-side security-Fn failures (a pre-transmission validation/authorization rejection of the caller's OWN outgoing message, structurally closer to REST's client-side param/credential validation — Category C, permanently excluded — than to a genuinely dispatched-and-then-failed message). - reqreply's server-side reply transmission gets the SAME failed-send
coverage as events' publish side (session-review round-3 addition):
a broker/socket-level rejection of the FINAL, successfully-encoded
reply (after the handler ran and the response encoded cleanly) is ALSO
dead-lettered — mirrors the publish-side bullet above exactly,
reusing
RouteHandle.DeadLetterFor/tryDeadLetterReflecton the SAME request-side topic/payload every other Category-A failure point in reqreply's server dispatch already uses.
Core-layer consolidation — DECIDED this round: DeadLetterFor, adapters only publish bytes¶
Applying the SAME "thin adapter" lens Topic 5 applies to
ObserveErrorResponseFor: the DECISION logic (is a DeadLetter
declared — globally or per-channel/route? build the envelope; report the
observer signal) is core-layer logic with NO transport-specific
dependency at all — only the actual act of PUBLISHING the built envelope
onto the wire is inherently adapter-specific (it needs the adapter's own
client/socket). Centralizing the former and leaving only the latter
to each adapter mirrors this document's own established discipline
(ErrorResponseFor/ObserveErrorResponseFor already work this way).
Correction this round: an earlier draft of this sketch had
DeadLetterFor pull the original raw payload out of ctx via an
invented rawPayloadFromContext(ctx) helper — this is NOT achievable.
Confirmed via code that each adapter (mqtt5, mqtt, zeromq) stores
its raw message using its OWN private, unexported contextKey{}
type (type contextKey struct{}, a different type per package) — a
CORE-layer function in api/events/api/reqreply has no access to any
adapter's private context key, and the raw message's Go TYPE differs per
adapter anyway (*pahomqtt5.Publish vs. pahomqtt.Message vs. raw
frames). The raw payload must instead be an EXPLICIT parameter, supplied
by the calling adapter (which already has it at hand at the point of a
decode-class failure — this is a small, honest cost, not a "zero adapter
involvement" claim):
// api/events (ChannelHandle) — api/reqreply's RouteHandle mirrors this
// exactly (Topic string field, RouteOpt instead of ChannelOpt).
//
// DeadLetterFor builds the dead-letter envelope for a failure at
// sourceTopic (the channel's OWN topic, or reqreply's request topic),
// reports it to obs (stats.ReportErrors(obs, "dead_letter", err)), and
// returns the destination topic + already-ENCODED envelope bytes ready
// to publish — or ok=false when no DeadLetter is declared (globally or
// on this channel) for this handle, in which case the adapter does
// nothing further. rawPayload is the original, undecoded message bytes
// (the adapter's own msg.Payload/msg.Payload()/frame — passed explicitly
// since a core-layer function cannot retrieve it from ctx: each adapter
// stores its raw message under its OWN private context key type). Does
// NOT itself consult ErrorResponseFor/ErrorChannelFor — callers are
// responsible for calling this ONLY after a more specific declared
// pattern has already been tried and missed (Tier 2), or immediately for
// decode-class failures where no such pattern could ever apply (Tier 1)
// — see the two-tier rule above.
func (h *ChannelHandle[T]) DeadLetterFor(
ctx context.Context, obs stats.Observer, sourceTopic string, rawPayload []byte, err error,
) (topic string, body []byte, ok bool) {
dl, declared := h.deadLetter() // resolves per-channel override, else the Builder-level global default
if !declared {
return "", nil, false
}
envelope := DeadLetterEnvelope{
SourceTopic: sourceTopic,
Payload: rawPayload,
Error: err.Error(),
Timestamp: time.Now().UTC(),
}
encoded, encErr := dl.codec.Marshal(envelope)
if encErr != nil {
stats.ReportErrors(obs, "dead_letter", encErr)
return "", nil, false
}
stats.ReportErrors(obs, "dead_letter", err)
return dl.topic, encoded, true
}
Adapter call-site impact: an adapter's dead-letter handling collapses
to if topic, body, ok := handle.DeadLetterFor(ctx, obs, handle.Topic, msg.Payload, err); ok { client.Publish(ctx, topic, body) }
— zero envelope-construction, encoding, or observer-reporting logic
remains adapter-side; the adapter's ONLY remaining responsibility is
passing the raw payload it already has, plus the transport-specific
Publish call itself, using whatever client/socket type it already has
in scope. This mirrors ObserveErrorResponseFor's
own "adapter just dispatches based on a returned value" shape exactly —
consistent thinning discipline across BOTH of this document's new
mechanisms, not just one of them.
Topic 5 — Observability: deep insight into which error pattern fired¶
Motivation¶
Confirmed via code reading across all 3 APIs' adapter dispatch sites
(adapters/nethttp/chi's serve.go, adapters/mqtt5/zeromq/mqtt's
adapter.go/binding.go, adapters/mqtt5/zeromq's
reqreply_transport.go): stats.ReportErrors(obs, "error_pattern"/
"error_channel", mapErr) is called ONLY when a MATCHED pattern's own
mapFn/encode itself fails — the rare sub-case. There is no observer
signal at all for the common, valuable case: "a declared ErrorPattern/
ErrorChannel successfully matched and fired." Today, callers can only
see the resulting numeric status code (e.g. HTTP 409, or a RecordPublish/
RecordRequest failure) — they cannot tell "this 409 was
domain.EmailConflictError" vs. "this 409 was some other declared
pattern" without inspecting response bodies out-of-band. REST's own
dispatch (adapters/nethttp/serve.go) has ZERO observer calls at either
of its two ErrorResponseFor call sites (middleware-fn error, handler
error) for the successful-match case — the most glaring instance of this
gap. The identical mechanism in adapters/mcpgo (MCP's own
apimcp.ErrorPattern) has the same gap, though MCP itself stays out of
this document's scope (see "Scope decisions") — noted here only as a
cross-reference, since a future MCP-focused round would likely want the
same fix using the same mechanism designed here.
This is a genuine "deep API insight" opportunity: a declared
ErrorPattern/ErrorChannel is, by construction, a catalogue of every
KNOWN, NAMED failure mode an API author has anticipated. Recording which
one fires, how often, and from where, turns that catalogue into a live
observability signal — hit-rate-per-error-type dashboards, alerting on an
unexpected spike in one declared error type, and (via RecordErrorPatternMiss,
below) a coverage signal for "this location keeps failing in ways NO
declared pattern anticipated — should one be added?".
Design goal: zero-effort observability for handler authors¶
The observer hook belongs at the DECLARATION/DISPATCH layer — never
inside the handler — specifically so a handler author gets this
observability for FREE, without writing a single line of instrumentation
code themselves. This mirrors every other observer call site already in
go-codex (RecordRequest, RecordSecurityRejection, RecordSubscribe/
RecordPublish): the adapter's dispatch loop calls the observer on the
handler's behalf, because the dispatch loop is the one place that already
sees every outcome, for every handler, uniformly — a handler author never
calls obs.RecordRequest themselves today, and should never need to call
an error-pattern-specific equivalent either.
Concretely: a handler author who declares
rest.ErrorPattern[domain.EmailConflictError, ErrorBody](409, ...) and
simply return ErrorBody{}, domain.EmailConflictError{...} from their
handler gets FULL observability — hit counts, structured logs, trace
tags — the moment RecordErrorPatternMatch is wired into the adapter
dispatch loop, with ZERO changes to the handler function itself. The
alternative (asking every handler author to manually call
obs.RecordSomething(...) before returning a declared domain error) would
re-introduce exactly the imperative, repeated-per-handler boilerplate the
declarative ErrorPattern/ErrorChannel mechanism itself was designed to
eliminate — inconsistent with the "declare → compose → register" workflow
the whole library is built around (see this skill's "User Experience
North Star"). This is not a new principle invented for Topic 5 — it is
the SAME principle that already justifies RecordRequest/
RecordSecurityRejection/every other adapter-owned observer call
existing today — Topic 5 is simply the same principle, applied to a
declaration surface (ErrorPattern/ErrorChannel) that has, until now,
been the one gap where it wasn't yet fully honored.
A useful acceptance test for any future implementation of this topic:
a handler function's source code should be IDENTICAL whether or not an
Observer is configured at all — adding/removing observability must
never require touching a single handler.
Proposed design¶
A new, optional stats.Observer extension — type-asserted at each
adapter call site, mirroring SecurityObserver's established pattern
exactly (purely additive; existing Observer implementations are
unaffected):
// stats/observer.go
// ErrorPatternObserver is an optional extension to [Observer] for
// declared-error-pattern observability — REST's ErrorPattern, events'
// ErrorChannel, and reqreply's ErrorPattern all share this ONE mechanism
// (their existing errors.As-matched, first-declared-wins, codec-backed
// design is already unified — see Topic 1). Adapters type-assert the
// configured Observer to ErrorPatternObserver before calling either
// method, so implementing this interface is purely additive.
type ErrorPatternObserver interface {
// RecordErrorPatternMatch is called when a declared ErrorPattern/
// ErrorChannel matches a failing operation's error via errors.As —
// regardless of whether the matched pattern's OWN encode/mapFn then
// succeeds or fails (that sub-case is separately reported via the
// existing stats.ReportErrors(obs, "error_pattern"/"error_channel", ...)
// mechanism, unchanged). location is the route path/topic template
// (same convention as RecordRequest). code identifies which declared
// pattern matched — REST: derived from status (e.g. "409"); events/
// reqreply: the pattern's own Code (WithCode or the sanitized-type-
// name default). action is the resolved action as a plain string
// ("respond"/"handle"/"log" for REST/events; "" for reqreply, which
// has no ErrorAction concept — always responds).
RecordErrorPatternMatch(location, code, action string)
// RecordErrorPatternMiss is called when a failing operation's error
// matched NO declared ErrorPattern/ErrorChannel (the raw/plain-text
// fallback path) — a coverage-analysis signal distinct from a match:
// "this location keeps failing in a way nothing declared here
// anticipated; should a pattern be added?"
RecordErrorPatternMiss(location string)
}
Why a plain string for action, not a shared typed value: stats
is a low-level package api/rest/api/events/api/reqreply all import
— it cannot import any of them back (cyclic). Confirmed via code that
reqreply.ErrorPattern has NO ErrorAction/WithAction concept at all
(unlike REST/events' 3-way ErrorRespond/ErrorHandle/ErrorLog) —
reqreply always "responds," since it is always answering a correlated
caller, with no separate "handle via existing callback" alternative the
way REST/events have. A plain string sidesteps both problems: no import
cycle, and no forced-empty field for reqreply's callers.
Why split into two methods, not one with a matched bool parameter:
mirrors SecurityObserver.RecordSecurityRejection's own precedent — a
purpose-named, single-outcome method, not a generic "check" method with a
boolean outcome flag. The two cases also have genuinely different useful
data: a match has a meaningful code+action; a miss has neither
(nothing matched) — forcing both into one signature would mean the miss
case passes a meaningless empty code/action. RecordErrorPatternMiss
also has standalone value as a coverage metric, independent of whether a
match ever happens elsewhere for that same location.
Core-layer consolidation — DECIDED this round: ObserveErrorResponseFor, keeping adapters thin¶
Confirmed real gap in this topic's OWN original design: as sketched
above, EVERY one of Topic 1's ~26+ Category-A call sites across REST/
events/reqreply would need to independently type-assert obs to
ErrorPatternObserver (twice — match and miss) AND to SpanTagger,
compute location/code/action per-API, and gate
RecordErrorPatternMiss behind HasErrorPatterns() — all repeated
adapter-side boilerplate. This directly contradicts the Design Goal
("keeps adapters thin") and this document's own established precedent
elsewhere (rest.DiagnosticObserver/Report*Errors, route.
FirstSchemeName, MiddlewareOutputError wrapped ONCE in each API's own
transform.go rather than per-adapter) — Topic 5's first draft simply
had not yet applied the same discipline to its OWN new mechanism.
Confirmed via code that every value this needs is already derivable
from the handle + the response ErrorResponseFor already returns —
zero adapter-supplied arguments needed beyond ctx, obs, and err:
location: REST derives it fromh.Descriptor.Path(the SAME conventionRecordSecurityRejectionalready uses atadapters/nethttp/serve.go); events/reqreply derive it fromh.Topic(both already exported fields onChannelHandle/RouteHandle).code: REST derivesstrconv.Itoa(resp.Status); events/reqreply useresp.Topic/resp.Codedirectly (both already present on the response structErrorResponseForalready returns).action: REST/events usestring(resp.Action); reqreply always""(noErrorActionconcept).
Fix: one new method per API, added ALONGSIDE ErrorResponseFor (NOT
replacing it — ErrorResponseFor stays available standalone for
contexts with no observer, e.g. client-side reply decode). REST's own
body, worked out concretely (api/events/api/reqreply mirror this
exactly, substituting their own location/code/action derivation
per the bullets above):
// api/rest/builder.go
// ObserveErrorResponseFor is the observability-aware counterpart of
// [RouteHandle.ErrorResponseFor]: it performs the SAME errors.As match,
// but ALSO reports the outcome to obs — RecordErrorPatternMatch on a
// match, RecordErrorPatternMiss on a miss (only when HasErrorPatterns()
// is true), and SpanTagger.TagSpan when both a match occurs AND obs
// implements SpanTagger — all type-asserted and derived INTERNALLY, so
// the adapter needs no knowledge of ErrorPatternObserver/SpanTagger/
// HasErrorPatterns at all. Returns the IDENTICAL 3-tuple
// ErrorResponseFor already returns, so an adapter's existing
// resp.Action-dispatch logic (write body, run ErrorHandle's callback,
// etc.) needs NO changes beyond swapping which method it calls.
func (h *RouteHandle[Req, Resp]) ObserveErrorResponseFor(
ctx context.Context, obs stats.Observer, err error,
) (resp ErrorPatternResponse, matched bool, applyErr error) {
resp, matched, applyErr = h.ErrorResponseFor(err)
location := h.Descriptor.Path
switch {
case matched && applyErr == nil:
if po, ok := obs.(stats.ErrorPatternObserver); ok {
po.RecordErrorPatternMatch(location, strconv.Itoa(resp.Status), string(resp.Action))
}
if st, ok := obs.(stats.SpanTagger); ok {
st.TagSpan(ctx, "error_pattern.code", strconv.Itoa(resp.Status))
}
case !matched && h.HasErrorPatterns():
if po, ok := obs.(stats.ErrorPatternObserver); ok {
po.RecordErrorPatternMiss(location)
}
}
return resp, matched, applyErr
}
Adapter call-site impact: every one of Topic 1's ~26+ call sites
shrinks from "call ErrorResponseFor, then manually wire 2-3
type-asserts" down to a single call —
handle.ObserveErrorResponseFor(ctx, obs, err) — with the exact same
return shape adapters already consume today. Zero behavior change to
the adapter's OWN subsequent dispatch logic; only the observability
wiring is centralized. This also means HasErrorPatterns() becomes an
internal implementation detail of ObserveErrorResponseFor — adapters
never need to call it directly (it remains exported for the rare caller
who wants the raw signal without going through this wrapper).
Structured logging expectations for E and B — DECIDED: recommend, do NOT hard-require¶
Metrics (ErrorPatternObserver) and traces (span tagging) are two of the
three observability pillars this document addresses — the third is
STRUCTURED LOGGING, via slog.LogValuer. This raised a natural question:
should ErrorPattern[E error, B any]'s two type parameters be required
to implement slog.LogValuer, mirroring go-codex's own internal
structured-error convention (every one of its own types —
MiddlewareInputError, SecurityError, DuplicateErrorPatternCodeError,
etc. — implements Error()+Unwrap()+LogValue() uniformly)?
Investigated concretely — decision: recommend/document, do NOT hard-require, for either type parameter.
E (the domain error, matched via errors.As) is already required to
implement error — that part is unchanged, enforced by the existing
generic constraint. The question is whether to ALSO require
slog.LogValuer. Confirmed via direct evidence this would be a real,
immediate breaking change: every domain error type currently used with
ErrorPattern in go-codex's OWN shipped examples —
EmailConflictError (examples/adapters-nethttp-client),
insufficientCreditError (examples/error-types), ConflictError
(examples/reqreply-api) — implements ONLY Error() string, none
implement slog.LogValuer. A hard requirement would fail to compile
against real, already-shipped code TODAY, not just hypothetical future
callers.
This also runs against how the wider Go ecosystem treats this exact kind
of interface. Checked log/slog's own source
(log/slog/value.go): LogValuer's Resolve() mechanism is built
ENTIRELY around duck-typing — it type-asserts whether a value implements
LogValuer and falls back to reflection-based default formatting
otherwise; the stdlib's OWN godoc frames it as an OPTIONAL enhancement
("may be used to defer expensive operations… or expand a single value
into a sequence of components"), never a requirement layered onto an
unrelated API. No comparable Go structured-logging interface (zap's
zapcore.ObjectMarshaler, zerolog's LogObjectMarshaler) is required by
an unrelated API anywhere in the broader ecosystem either — this would be
a novel departure, not an established pattern being extended.
Three further costs, independent of the breaking-change evidence:
- Logging-library coupling:
ErrorPattern/stats.Observerare otherwise pluggable and logging-library-agnostic (a caller may usezap,zerolog, plainfmt, or nothing at all) — hard-requiringslog.LogValuerspecifically would privilege ONE logging package at the type-system level, for zero benefit to callers who don't use it. - Forced boilerplate with no default-method escape hatch: Go has no
mechanism to auto-derive a sensible
LogValue()from a struct's own fields — every caller would need to hand-write one for every domain error type, directly contradicting this document's own Design Goal (reduce boilerplate, not add it). - No compile-time value for
Beven if considered:Bis a wire payload DTO, not a Go error value in the common "Mapped" mode — forcingslog.LogValuer(orerror) onto it would be an arbitrary constraint with no corresponding ergonomic upside, for the same duck-typing reasons asE.
Decision: keep slog.LogValuer fully OPTIONAL and duck-typed for both
E and B, exactly as log/slog itself treats it everywhere else —
document/recommend it (mirroring go-codex's own internal convention) as
good practice for callers who want richer structured logs, with a short
example in a future guide update, but never enforce it via the generic
constraint. B additionally should NOT be required to implement error
at all (a wire DTO has no need for Go error semantics in the common
Mapped-mode case) — already noted, reaffirmed here for completeness.
ErrorPatternResponse.LogValue() (already shipped, all 3 client
packages) already calls slog.Any("value", e.Value), which automatically
invokes B's own LogValue() if present — so a caller who DOES choose to
implement it gets richer logs for free, with zero additional code needed
anywhere else in the mechanism.
Trace integration¶
Superseded by the "Core-layer consolidation" subsection above:
ObserveErrorResponseFor already calls SpanTagger.TagSpan internally
on a match (type-asserted, exactly like ErrorPatternObserver) — an
adapter gets trace tagging "for free" from the SAME single call it makes
for match/miss recording, with no separate wiring step. This lets a
trace immediately show why a request failed (which declared error
type), not just that it failed (a bare status code) — the difference
between "500 errors spiked" and "EmailConflictError spiked,
InventoryShortageError did not" in a trace-driven investigation.
Call sites (all 3 APIs — mirrors Topic 1's FULL enumeration, not just handler/middleware-Fn)¶
Every Category-A row in Topic 1's per-API enumeration tables is, by
construction, a future ObserveErrorResponseFor call site too — the
observer hook rides along with WHATEVER call sites Topic 1's fix
touches, at every one of them, not a separately-chosen subset. Per the
"Core-layer consolidation" subsection above, each row below now means
"replace this row's ErrorResponseFor call with ObserveErrorResponseFor"
— a one-line adapter change per row, not a multi-line type-assert
addition:
| API | Already-shipped call site(s) (ship alongside ErrorPatternObserver itself) |
Newly in scope (ship alongside Topic 1's fix, same rollout) |
|---|---|---|
| REST | adapters/nethttp/chi's serve.go — middleware-fn error branch AND handler error branch (2 sites, currently ZERO observer calls at either) |
The other 10 Category-A rows (matches Topic 1's "10 of 12" count): body decode, path/query/cookie/header param validation, middleware DecodeIn/EncodeOut, response merge-field encode, response body encode, security middleware Fn |
| events | adapter.go/binding.go handler+middleware-Fn dispatch (mqtt5/zeromq/mqtt) |
Payload decode, topic/property-var merge, middleware DecodeIn/EncodeOut, security middleware Fn |
| reqreply | reqreply_transport.go's publishHandlerErrorReplyReflect/sendHandlerErrorReplyReflect/sendRouterHandlerErrorReplyReflect |
Request decode, topic/property-var merge, middleware DecodeIn/EncodeOut, security middleware Fn |
This is a direct consequence of the "reduce observability effort in
handlers" design goal applied consistently and at full scope: closing
Topic 1's now-fully-enumerated gap and NOT ALSO wiring
ErrorPatternObserver into every one of those SAME call sites would
leave the vast majority of typed-response paths invisible to the SAME
observability this document otherwise guarantees for the 2 already-
shipped call sites — an inconsistency this table exists to prevent from
being introduced by omission. Category B (codex)/Category C
(ports/adapter infrastructure) errors correctly KEEP their own existing,
separate observer calls (RecordValidationError, RecordRequest
status=0, etc.) — Topic 5 does not touch or duplicate those; it only
adds the NEW signal for the response-shape-relevant subset.
Design decisions — DECIDED this round¶
TraceObserver's span-tagging hook: a NEW, SEPARATE, optional interface —stats.SpanTagger— rather than a new method added directly to the existingTraceObserverinterface:
// stats/observer.go
// SpanTagger is an optional extension to [Observer]/[TraceObserver] —
// implement it to tag the CURRENT active span with additional
// key/value context (e.g. which declared ErrorPattern/ErrorChannel
// fired). Callers do NOT type-assert this directly — per the
// "Core-layer consolidation" subsection above, ObserveErrorResponseFor
// is the ONLY caller of SpanTagger, mirroring SecurityObserver's
// established type-assertion pattern internally — purely additive,
// zero adapter-side wiring.
type SpanTagger interface {
TagSpan(ctx context.Context, key, value string)
}
Confirmed via grep -rn "func.*StartSpan(ctx context.Context" that
TraceObserver has ~13 existing concrete implementers today (test
spies across 8 packages, stats.fanout/NoopObserver, and 2 real
examples) — adding a required 3rd method directly to TraceObserver
would break every one of them. A separate, independently
type-asserted SpanTagger interface avoids this entirely: callers who
want span tagging implement one more small interface; everyone else is
unaffected.
- RecordErrorPatternMiss fires ONLY when at least one
ErrorPattern/ErrorChannel IS declared on that route/channel — NOT
for every unmatched error unconditionally. Confirmed via code
(RouteHandle.ErrorResponseFor's errorPatternRules slice is
currently unexported, with no existing accessor) that this needs a new,
small, exported accessor: RouteHandle.HasErrorPatterns() bool
(api/rest, api/reqreply) / ChannelHandle.HasErrorPatterns() bool
(api/events) — a one-line len(h.errorPatternRules) > 0 wrapper.
Per the "Core-layer consolidation" subsection above, this accessor is
called INTERNALLY by ObserveErrorResponseFor — adapters never call
it directly; it stays exported only for the rare caller who wants the
raw signal outside that wrapper. This keeps the coverage signal
meaningful: "this location keeps failing in a way NOTHING declared
here anticipated" (a real signal worth alerting on) vs. "nobody
declared anything for this location at all" (not a coverage gap,
simply not applicable) stay distinguishable.
- LoggingObserver/NoopObserver/fanout implementations:
mechanical, no separate design decision — these ship alongside the new
ErrorPatternObserver/SpanTagger interfaces themselves, as part of
the same rollout (mirrors every other optional extension's existing
rollout checklist).
- One-line MCP cross-reference: adapters/mcpgo's apimcp.ErrorPattern
has the IDENTICAL "no signal for a successful match" gap — out of this
document's scope, but a future MCP-focused round should reuse
stats.ErrorPatternObserver unchanged rather than inventing a second
mechanism.
Topic 6 — Client-side ergonomics: working with matched patterns conveniently¶
Motivation¶
Topics 1/Phase 0 close the SERVER-side declaration gap (every failure
point becomes ErrorPattern-eligible) and the wire round-trip (client
receives a typed, decoded payload via nethttp.ErrorPatternResponse/
mqtt5.ErrorPatternResponse/zeromq.ErrorPatternResponse). But "declare
on the server" and "use conveniently on the client" are DISTINCT
concerns — this topic is about the latter specifically.
Today's client-side workflow (unchanged since Phase 0) is a 2-step manual dance:
var patternResp nethttp.ErrorPatternResponse
if errors.As(err, &patternResp) {
switch v := patternResp.Value.(type) {
case domain.EmailConflictError:
// ...
case domain.ValidationError:
// ...
default:
// unexpected/unmapped payload type
}
}
This works and is already documented (docs/guides/http-client.md,
docs/guides/asyncapi.md's "Client-side decode" section) — but requires
the caller to manually repeat errors.As + a type switch on any for
every call site, with no compile-time connection back to which
ErrorPattern declaration produced which case. This topic sketches 3
alternatives to make this more convenient — all 3 are now DECIDED,
shipping together (see the subsection immediately below).
DECIDED this round: build all 3 alternatives together¶
All 3 alternatives below ship together — they are complementary, not
competing: Alternative 1 is the primary, zero-usage-shift mechanism;
Alternative 2 is a thin wrapper over it for callers who already keep
declarations as package-level vars; Alternative 3's original
"reflection-heavy, not recommended" framing turned out to be WRONG on
re-inspection this round (see below) — a clean, zero-reflect
implementation exists, so it ships too rather than staying deferred.
Alternative 1 — ErrorPatternAs, a generic one-line decode helper (primary mechanism)¶
Renamed from the original placeholder DecodeErrorPatternAs →
ErrorPatternAs this round — "Decode" was misleading, since nothing is
decoded AT this call; the wire decode already happened earlier, inside
DecodeErrorFor. This function only extracts the already-decoded
Value — "As" mirrors errors.As/Topic 7's SubscribeError.As naming
family directly.
CORRECTED in a later review round: this section originally placed
ErrorPatternAs(andHandleErrorPattern/Case, Alternative 3 below) in EACH client adapter package (adapters/nethttp,adapters/mqtt5,adapters/zeromq), reasoning "3/2 near-identical copies... mirroring the existing precedent that each adapter keeps its OWNErrorPatternResponse/CallErrortype." That reasoning was a design mistake — it conflated two different things. It IS correct that the CONCRETE response type (nethttp.ErrorPatternResponse,mqtt5.ErrorPatternResponse,zeromq.ErrorPatternResponse) legitimately differs per adapter (protocol-specific fields likeStatusCodevsCode). ButErrorPatternAs/HandleErrorPattern/Casetouch ONLY the shared, already-core-layerErrorPatternValuerinterface (Alternative 2 below already used this same interface correctly) — they have ZERO protocol-specific logic and never needed to live in an adapter at all. This violated this library's own "thin adapter" design guardrail (see the new subsection below) — a real, user-reported design gap, not a style preference. Fixed:ErrorPatternAs/HandleErrorPattern/Casenow live inapi/restandapi/reqreply(ONE implementation each, not one per adapter) —mqtt5andzeromqno longer each carry a byte-for-byte-identical copy. The code example below is kept for illustration but now describes the CORE-layer implementation, not an adapter-owned one.
// api/rest and api/reqreply each gain this ONCE (not once per adapter)
// — NOT adapters/chi (no client exists there at all) and NOT api/events
// (no synchronous caller — Topic 2's reasoning applies identically here).
//
// ErrorPatternAs extracts a matched ErrorPattern's typed payload in one
// call, collapsing the errors.As + type-switch dance above into a
// single conditional. Works against ANY adapter's own error-pattern
// response type, since all of them implement ErrorPatternValuer (see
// Alternative 2) — this function never needs to know which adapter
// produced err.
func ErrorPatternAs[B any](err error) (B, bool) {
var target ErrorPatternValuer // the SAME core-layer interface Alternative 2 uses
if !errors.As(err, &target) {
var zero B
return zero, false
}
b, ok := target.ErrorPatternValue().(B)
return b, ok
}
Usage (unchanged for callers, only the import path changed):
if conflict, ok := rest.ErrorPatternAs[domain.EmailConflictError](err); ok {
return promptDifferentEmail(conflict.Email) // conflict is fully typed
}
Low-risk, purely additive — no change to the existing ErrorPatternResponse
type or any existing behavior. ONE implementation per API (api/rest,
api/reqreply), reused transparently by every adapter that implements
that API's ErrorPatternValuer interface — not "one per adapter."
Alternative 2 — .Match method on the declaration value (thin wrapper over Alternative 1)¶
// api/rest (and api/reqreply's identical mirror) — ErrorPatternOpt[E,B],
// the value rest.ErrorPattern[E,B](...)/reqreply.ErrorPattern[E,B](...)
// already returns, gains a client-facing Match method.
func (o ErrorPatternOpt[E, B]) Match(err error) (B, bool) {
// delegates to the SAME generic decode as Alternative 1, scoped to
// THIS declaration's own B type — the compiler already knows B from
// o's own type parameters, so no explicit [B] instantiation is
// needed at the call site. Plain (B, bool) return, matching a
// standard Go type-assertion idiom — NO dedicated error type, since
// there is nothing more specific to report than "this err did not
// come from this declaration."
}
Usage — the SAME value declares the pattern (server) AND matches it (client), embodying go-codex's existing "declare once, use both directions" philosophy directly:
// Declared ONCE, at package scope:
var emailConflictPattern = rest.ErrorPattern[domain.EmailConflictError, domain.EmailConflictError](409, conflictCodec)
// Server: attached via NewRoute's variadic opts.
route := rest.NewRoute[CreateUserReq, User]("POST", "/users", reqCodec, userCodec,
emailConflictPattern,
)
// Client: the SAME value matches the decoded response.
resp, err := nethttp.CallWithHandle(ctx, client, baseURL, handle, req, opts)
if conflict, ok := emailConflictPattern.Match(err); ok {
// conflict is domain.EmailConflictError, fully typed
}
More self-documenting (the match call sits right next to the
declaration, no need to remember/import the exact payload type
separately) but requires callers to KEEP the declaration as a
package-level var instead of inlining ErrorPattern(...) directly into
NewRoute's variadic args — a real usage-pattern shift from how most
existing examples/tests declare patterns today (inline, not as a
separate var); not a blocker, just a different, ADDITIONAL style — the
inline style keeps working unchanged. What Match does if B collides
with another declared pattern's B on the SAME route is moot in
practice now that Topic 1's reopened fix makes REST reject 2+
same-status ErrorPatterns at Register time
(DuplicateErrorStatusError), exactly mirroring reqreply's existing
DuplicateErrorPatternCodeError rejection — both APIs now hard-reject
the ambiguous case up front, so Match never has to arbitrate a
collision at call time; it is just a thinner FRONT END over the SAME
existing DecodeErrorFor/ErrorResponseFor machinery.
Alternative 3 — HandleErrorPattern, a declarative dispatch table (CORRECTED this round: no reflection needed)¶
Correction: the original framing of this alternative claimed it was
"reflection-heavy" and needed reflect.TypeOf on each closure. Re-examined
this round — that claim was WRONG. A clean, zero-reflect Go-generics
implementation exists, using an interface + per-case type assertion (the
same "typed case" idiom used elsewhere for heterogeneous
type-parameterized collections in Go):
CORRECTED in a later review round: same placement fix as Alternative 1 above —
HandleErrorPattern/Casemoved fromadapters/nethttp/adapters/mqtt5/adapters/zeromqintoapi/rest/api/reqreply(ONE implementation each). The code below is kept for illustration but now describes the core-layer implementation.
// api/rest and api/reqreply each gain this ONCE — same scope as
// Alternatives 1/2.
// errorCase is the internal, type-erased interface each Case[T] value
// implements — this is what makes a slice of heterogeneous Case[T]
// values possible without reflect.
type errorCase interface {
tryHandle(value any) bool
}
type typedCase[T any] struct {
fn func(T)
}
// Case declares one typed handler for HandleErrorPattern — T is
// inferred from fn's own parameter type, so no explicit [T]
// instantiation is needed at the call site.
func Case[T any](fn func(T)) errorCase {
return typedCase[T]{fn: fn}
}
func (c typedCase[T]) tryHandle(value any) bool {
v, ok := value.(T) // ordinary type assertion — NOT reflect.TypeOf
if !ok {
return false
}
c.fn(v)
return true
}
// HandleErrorPattern extracts a matched ErrorPattern's typed payload
// ONCE (a single errors.As call, unlike each Case doing its own), then
// dispatches to the first Case whose T matches the payload's concrete
// type. Returns false when err carries no ErrorPatternValuer value at
// all, OR when it does but no Case's T matches its value's concrete
// type.
func HandleErrorPattern(err error, cases ...errorCase) bool {
var target ErrorPatternValuer // the SAME core-layer interface Alternative 2 uses
if !errors.As(err, &target) {
return false
}
for _, c := range cases {
if c.tryHandle(target.ErrorPatternValue()) {
return true
}
}
return false
}
Usage:
handled := rest.HandleErrorPattern(err,
rest.Case(func(e domain.EmailConflictError) { promptDifferentEmail(e.Email) }),
rest.Case(func(e domain.ValidationError) { showValidationErrors(e) }),
)
if !handled {
// no case matched — unmapped payload type, or no ErrorPattern matched at all
}
This gives the closest visual parity to a switch/match expression —
the type is inferred from each closure's own parameter, so a caller never
has to name it explicitly at the call site (unlike Alternative 1's
[B] instantiation). Real, remaining trade-offs versus Alternatives ½
(honestly assessed, not a reason to defer — just context for when to
reach for which):
- It is a fully SEPARATE mechanism, not a thin wrapper over Alternative 1 (unlike Alternative 2) — genuinely new maintenance surface, though (per the correction above) only ONE implementation per API, not one per adapter.
- It is client-only — no "declare once, use both directions" angle the
way Alternative 2 has (
ErrorPatternOpt.Matchreuses the SAME value the server declares with). Case[T]is intended for CONCRETE domain error/payload types (mirrorsErrorPattern[E, B]'s own typical usage) — using an interface type forTcould make dispatch order matter if a value satisfies more than one registeredCase's interface; this is the same "first-declared-wins" precedent already established elsewhere in this document (Topic 1), not a new kind of ambiguity, but worth a one-line godoc callout when implemented.
Design guardrail: adapters implement wire protocols only — client-side ergonomics belong in api/*¶
Added in the SAME later review round that corrected Alternatives ⅓
above, generalizing the specific mistake into a permanent, explicit rule
for this library (and for this skill's own review checklist, which now
cross-references it — see .github/skills/review-go-codex/references/checklist.md
§13):
Adapters implement wire protocols only. Any user-facing convenience or ergonomic helper that touches ONLY core
api/*types — codecs, handles, declared patterns, or a core-layer interface likeErrorPatternValuer— belongs inapi/*, never inadapters/*. This holds EVEN WHEN, at the time of writing, only one adapter happens to implement that boundary (as REST'snethttpdid before this fix) — "only one adapter exists today" is not a justification for placing transport-independent logic inside that one adapter; the test is whether the logic COULD be written using onlyapi/*types, not how many adapters currently exist.
This is the client-side/consumption-side mirror of the "thin adapter"
principle Category A's server-side dispatch consolidation (Topic ⅕,
ObserveErrorResponseFor/DeadLetterFor) already established — that
work made adapters call ONE shared, generic core-layer method at every
Category-A dispatch point instead of hand-rolling per-adapter logic.
ErrorPatternAs/HandleErrorPattern/Case are the exact same shape of
mistake on the OTHER side of the boundary: convenience helpers a CALLER
uses after receiving a response, which — exactly like the server-side
dispatch helpers — only ever need errors.As against a core-layer
interface (ErrorPatternValuer), never anything adapter-specific.
The user-experience promise this protects: a caller should be able
to declare and consume a communication pattern using ONLY the api/*
abstraction — attaching a specific adapter is purely a protocol-selection
decision, never something that changes which helper functions/vocabulary
the caller reaches for. rest.ErrorPatternAs/reqreply.ErrorPatternAs
(not nethttp.ErrorPatternAs/mqtt5.ErrorPatternAs/zeromq.ErrorPatternAs)
is what makes the whole workflow genuinely protocol-independent, matching
this document's own "declare it, don't dispatch it" framing for the
server side.
Audited against all 3 APIs this round — confirmed api/events (pub/sub)
has NO equivalent gap: pub/sub has no synchronous caller to hand a
matched error back to (Topic 2's reasoning), so a downstream consumer
just subscribes to the declared error-output topic as an ordinary typed
channel — already core-layer, already protocol-independent, nothing to
move. mcp.ErrorPattern and websocket.ErrorFrame were also checked and
confirmed structurally different (MCP tool errors are structured
CallToolResult values, never a Go error the caller matches via
errors.As; ErrorFrame broadcasts to all sessions, it isn't a
call/response the caller receives a matched error back from) — neither
has an analogous client-side helper to misplace.
Retry/idempotency guidance (documentation addition, no code change)¶
Worth stating explicitly and prominently once any of Alternatives 1–3
ship, since it's easy to get wrong: a matched ErrorPatternResponse is
a BUSINESS DECISION the server made deliberately — never safe to blindly
retry — fundamentally different from a transport-level failure like
nethttp.RequestError/mqtt5.CallError{Kind: KindTimeout}, which
usually IS safe to retry (network blip, DNS hiccup, broker unavailable).
Retrying an EmailConflictError or InsufficientCreditError response
verbatim will just produce the SAME business rejection again — the
caller needs to change something (a different email, wait for a balance
top-up) before retrying makes sense, if it ever does. Today's guide
(docs/guides/http-client.md) already states a similar "rule of thumb"
for nethttp.RequestError specifically, but doesn't yet call out the
CONTRASTING case for a matched ErrorPatternResponse explicitly — worth
adding alongside whichever Topic 6 alternative ships, so the two
"opposite" pieces of guidance sit next to each other rather than only
one being stated.
Scope¶
Applies to: adapters/nethttp (REST client), adapters/mqtt5/
adapters/zeromq (reqreply client, both REQ/REP and ROUTER/DEALER for
zeromq). Does NOT apply to: adapters/chi (no client exists),
api/events (no synchronous caller to decode a reply for — reaffirms
Topic 2's reasoning).
Design decisions — ALL DECIDED this round¶
- Which alternative(s) to build: all 3, together (see "DECIDED this round" above).
- Naming:
ErrorPatternAs[B any](err error) (B, bool)(renamed from the placeholderDecodeErrorPatternAs). Match's (Alternative 2) return shape: plain(B, bool), no dedicated error type — simpler, idiomatic, mirrors a standard Go type-assertion.- Whether Alternative 1 and 2 ship together: yes, both — Alternative 2 is implemented as a thin wrapper calling Alternative 1 internally.
- Alternative 3's status: no longer "roughest, most speculative" —
its original "reflection-heavy" framing was found to be inaccurate
this round (a zero-
reflectimplementation exists, shown above); it ships alongside Alternatives ½ rather than staying deferred.
Topic 7 — Pub/sub role clarification + subscriber-side error convenience¶
Role clarification: publisher = client/sender, subscriber = receiver/server¶
Investigated whether pub/sub's PUBLISH side has the same ErrorChannel
gap as the subscribe side (Topic 1's enumeration). It does not — but the
reasoning is worth stating explicitly, since it resolves a real point of
confusion (initially mis-read as a gap while researching this topic).
Confirmed via code (mqtt5/zeromq adapter.go's publish function): a
publish-side ClientMiddlewareHandlers Fn error is returned directly to
whoever called Publish(...), NEVER consulting ErrorResponseFor. This
is architecturally CORRECT, not a gap — confirmed via REST's own
documented precedent (adapters/nethttp/client_middleware.go's
dispatchClientMiddlewareIn doc comment): "client-side fn errors are
NOT run through ErrorResponseFor — that mechanism shapes SERVER
responses; a client-side error is simply returned to the caller
directly." Confirmed identical in reqreply's client-side dispatch.
ErrorPattern/ErrorChannel is architecturally a RECEIVER/SERVER-side
concept — it exists to shape a WIRE RESPONSE for someone else to
receive. Pub/sub's PUBLISHER plays the exact same "client/sender" role
REST's/reqreply's CLIENTS already play — correctly excluded, for the
identical reason. Pub/sub's SUBSCRIBER plays the "receiver/server" role —
where ErrorChannel correctly belongs, and where Topic 1's Category-A
fix + Topic 4's dead-letter mechanism already apply.
| Role | REST | reqreply | events | ErrorPattern/ErrorChannel-eligible? |
|---|---|---|---|---|
| Receiver/server | Route handler | Server handler | Subscribe handler | Yes (Topic 1) |
| Client/sender | Client.Call's ClientMiddlewareHandlers |
Same | Publish's ClientMiddlewareHandlers |
No, by design — no wire response exists to shape |
No code change needed here — this is a confirmed, already-correct, consistent design across all 3 APIs; stated explicitly so a future round doesn't "fix" publish-side exclusion by mistaken analogy with the subscribe-side gap Topic 1 closes.
Subscriber-side error convenience: a thin, decode-free helper¶
Once Topic 1's Category-A fix lands, a subscribe-side failure that
matches NO declared ErrorChannel still reaches the caller's OnError
callback as a raw SubscribeError{Kind, Topic, Err} — the SAME kind of
"caller must manually errors.As/type-switch on .Err" ergonomic gap
Topic 6 identifies for REST/reqreply's CLIENT side, but with one key
difference: no wire decode is involved. The subscriber already HAS
the original Go error value natively (it was never encoded/decoded across
a wire boundary for this purpose) — so Topic 6's ErrorPatternAs
(which exists specifically to decode WIRE BYTES into a typed Go value)
does not apply here; what's needed instead is a much simpler convenience:
extracting a specific error type from the (already-Go-native)
SubscribeError.Err chain in one call instead of two.
// adapters/mqtt5, adapters/zeromq, adapters/mqtt each gain this — mirrors
// Topic 6's naming, but semantically simpler: no decode step, purely a
// thin errors.As wrapper scoped to SubscribeError's own Err field.
func (e SubscribeError) As(target any) bool {
return errors.As(e.Err, target)
}
Usage — collapses errors.As(subErr.Err, &target) into
subErr.As(&target), a marginal but real ergonomic win, self-documenting
as "check what THIS subscribe failure actually was":
opts.OnError = func(subErr mqtt5.SubscribeError) {
var conflict domain.EmailConflictError
if subErr.As(&conflict) {
// ...
}
}
This is a SMALL, low-risk addition (a one-line method, mirrors the
standard library's own errors.As naming convention directly) — flagged
here for completeness rather than as a major design point, since the
bulk of the subscriber-side convenience win already comes from Topic 1's
Category-A fix (most failures never reach OnError at all once a
matching ErrorChannel is declared — declare once, zero handler code,
per the Design Goal).
Publisher side: no new mechanism needed¶
Confirmed: existing structured errors (events.MiddlewareError/
MiddlewareOutputError, already shipped) already give a publish caller
everything Topic 6-style ergonomics would otherwise provide — no wire
decode is involved (the error is a native Go value the SAME process just
produced), so there is nothing to add here beyond what already exists.
Implementation planning¶
Backward compatibility summary¶
Breaking-vs-additive status is scattered across each topic's own text — consolidated here for a reviewer's quick reference. Per the Breaking Changes Policy (above), breaking changes need NO special process (no changelog callout, no major-version gating, no opt-in flag/transition period) — this table is for AWARENESS of scope/impact, not because breaking-ness gates any decision:
| Topic | Change | Breaking? |
|---|---|---|
| Phase 0 | reqreply client-side ErrorPattern decode |
Already shipped, additive — no impact here |
| Topic 1 | Extend ErrorPattern/ErrorChannel eligibility to every Category-A failure point |
Additive — a route/channel with NO matching declared pattern for a given failure point sees ZERO behavior change; only routes that ADD a new matching declaration gain new behavior |
| Topic 1 (reopened) | REST rejects 2+ ErrorPatterns sharing one status |
BREAKING, narrowly scoped — changes TestDecodeErrorFor_FirstMatchWins_SameStatus's expected outcome from silent first-match-wins to a hard Register-time rejection; any existing route/test/example declaring 2+ same-status patterns needs a quick fix (distinct statuses) |
| Topic 2 | Ratify same-reply-topic behavior | No code change — documentation only, behavior already shipped |
| Topic 3 | Migrate reqreply's AsyncAPI rendering to multi-message reply channels | BREAKING to the GENERATED SPEC SHAPE for any route with 2+ declared ErrorPatterns — the separate <route>ReplyError<Code> channel keys disappear, folded into the reply channel's messages map. No Go API signature changes; existing ErrorPattern/ErrorReplyMeta declarations need no caller-side changes — only the RENDERED spec output differs for consumers of that spec (codegen tools, doc sites) |
| Topic 4 | New DeadLetter mechanism |
Additive — entirely new, opt-in ChannelOpt/RouteOpt; routes/channels declaring nothing see no change |
| Topic 5 | New stats.ErrorPatternObserver extension |
Additive — optional, type-asserted interface extension; existing Observer implementations are unaffected |
| Topic 6 | New client-side decode helper(s) | Additive — new functions/methods, no existing API changes |
| Topic 7 | Role clarification + SubscribeError.As |
No code change for the role clarification (documentation only, confirms already-correct behavior); SubscribeError.As is a new, additive method |
Two topics now carry a genuine breaking change (Topic 1's reopened REST same-status fix, and Topic 3's AsyncAPI spec-shape migration) — both narrowly scoped, both pre-approved under the Breaking Changes Policy, both ship directly with no transition period. Everything else is additive or documentation-only.
Suggested phased implementation order¶
Not a strict requirement — but the topics have real dependencies worth sequencing around:
- Topic 1 (full enumeration fix) + Topic 5 (observer wiring), together,
per API — ship these AS ONE UNIT, one API at a time (e.g. REST
first, then events, then reqreply) rather than doing all of Topic 1
before starting Topic 5. Topic 5's observer hooks ride along with
WHATEVER call sites Topic 1's fix touches at each step — implementing
them separately would mean either (a) temporarily wiring observer
hooks into call sites
ErrorResponseFordoesn't consult yet (dead code), or (b) a second pass through the same call sites shortly after the first (wasted rework). Doing both together, per call site, per API, avoids both. - Topic 4 (dead-letter) — independent of ⅕, can proceed in
parallel or before/after; its own adapter-dispatch wiring touches
DIFFERENT branches (the truly-unmatched fallback) than Topic ⅕'s
ErrorResponseFor-consultation branches, though they're adjacent code. Recommend doing Topic ⅕ FIRST per API, since Topic 4's dead-letter fires only once Topic 1's fullerErrorResponseForconsultation has ALSO found no match — sequencing them in this order avoids threading the dead-letter fallback through call sites that don't have theErrorResponseForconsultation wired yet. - Topic 3 (AsyncAPI multi-message rendering) — fully independent of
¼/5; touches
render/asyncapi/v3+api/reqreply/builder.goonly. Can ship anytime, including FIRST, if the breaking spec-shape change is more convenient to land before other reqreply-touching work is in flight (fewer merge-conflict surfaces). - Topic 6 (client-side ergonomics) — depends only on Phase 0
(already shipped) — the client-side
ErrorPatternResponse/mqtt5.ErrorPatternResponse/zeromq.ErrorPatternResponsetypes this topic's helpers wrap around already exist. Can ship anytime, fully independently of ⅓/⅘. - Topic 2 (ratification) + Topic 7 (role clarification) — pure
documentation, no code dependency on anything; can ship whenever
convenient, including alongside the doc's own eventual graduation to
docs/design/d-0005.
Unit test plan (sketch — to be expanded once API surface is finalized)¶
| Test | Verifies |
|---|---|
TestErrorPattern_DuplicateStatus_Rejected (api/rest) |
Topic 1's reopened fix: 2+ ErrorPatterns sharing one status on the SAME route are rejected at Register/RegisterHandle time with rest.DuplicateErrorStatusError — REPLACES TestDecodeErrorFor_FirstMatchWins_SameStatus's old expected behavior |
TestAsyncAPI_ReplyChannel_MultipleMessages (api/reqreply) |
A route with 2+ ErrorPatterns renders ONE reply channel with a messages map containing all variants, no separate error channels |
TestAsyncAPI_ReplyChannel_SingleMessage_BackwardCompatible (api/reqreply) |
A route with ZERO declared ErrorPatterns renders identically to today (no regression for the common case) |
TestDeadLetter_UnmatchedError_Published (api/events, api/reqreply) |
An error matching no declared ErrorChannel/ErrorPattern gets published to the declared dead-letter topic with the correct envelope |
TestDeadLetter_MatchedError_NotPublished (api/events, api/reqreply) |
An error THAT DOES match a declared ErrorChannel/ErrorPattern does NOT ALSO reach the dead-letter topic (strict fallback-tier ordering) |
TestDeadLetter_DecodeFailure_Published (api/events, api/reqreply) |
A subscribe/request decode failure (no business error exists yet) reaches the dead-letter topic |
TestObserveErrorResponseFor_RecordsMatch (api/rest, api/events, api/reqreply) |
Core-layer consolidation: ObserveErrorResponseFor calls RecordErrorPatternMatch with the correct location/code/action on a match, returning the SAME 3-tuple ErrorResponseFor would |
TestObserveErrorResponseFor_RecordsMiss_OnlyWhenPatternsDeclared (api/rest, api/events, api/reqreply) |
ObserveErrorResponseFor calls RecordErrorPatternMiss when unmatched AND HasErrorPatterns() is true; does NOT call it when no patterns are declared at all |
TestObserveErrorResponseFor_TagsSpan_OnMatch (api/rest, api/events, api/reqreply) |
ObserveErrorResponseFor calls SpanTagger.TagSpan on a match when obs implements it |
TestObserveErrorResponseFor_PlainObserver_NoPanic (api/rest, api/events, api/reqreply) |
An Observer implementing NEITHER ErrorPatternObserver NOR SpanTagger causes no panic — both type-assertion guards live inside ObserveErrorResponseFor, adapters never need their own guard |
TestDeadLetterFor_UndeclaredHandle_ReturnsFalse (api/events, api/reqreply) |
DeadLetterFor returns ok=false (and does nothing else) when no DeadLetter is declared globally or per-channel/route |
TestDeadLetterFor_ReportsObserver_AndReturnsEncodedEnvelope (api/events, api/reqreply) |
DeadLetterFor reports stats.ReportErrors(obs, "dead_letter", err) and returns the correct topic + already-encoded envelope bytes when a DeadLetter IS declared |
Test<FailurePoint>_ErrorPatternMatched_RespondsTyped (one per Category-A row per API — e.g. TestBodyDecodeError_ErrorPatternMatched, TestSecurityMiddlewareError_ErrorPatternMatched, TestMiddlewareEncodeOutError_ErrorPatternMatched, TestResponseEncodeError_ErrorPatternMatched) |
A failure at THIS row matching a declared ErrorPattern/ErrorChannel produces the TYPED response — mirrors the already-shipped handler/middleware-Fn tests, applied to every remaining Category-A row |
Test<FailurePoint>_NoErrorPattern_FallsBackUnchanged (same rows, same packages) |
Same failure point with NO matching pattern still produces today's UNCHANGED fixed shape — regression guard for every existing caller, one per row |
TestErrorPattern_MatchesCodexErrorType (api/rest) |
A declared rest.ErrorPattern[codex.ValidationErrors, Body](...) matches a REAL body-decode failure once Category A's body-decode row is wired — proves Category B's "zero new codex awareness needed" claim end-to-end |
TestErrorPatternAs_Matches/_NoMatch (adapters/nethttp, adapters/mqtt5, adapters/zeromq) |
Topic 6 Alternative 1: the generic helper correctly extracts the typed payload on a match, returns ok=false on any non-ErrorPatternResponse error or a Value of the wrong concrete type |
TestErrorPatternOpt_Match_Matches/_NoMatch (api/rest, api/reqreply) |
Topic 6 Alternative 2: .Match correctly delegates to Alternative 1's decode logic, scoped to the declaration's own B type |
TestHandleErrorPattern_DispatchesFirstMatchingCase/_NoCaseMatches/_NotAnErrorPattern (adapters/nethttp, adapters/mqtt5, adapters/zeromq) |
Topic 6 Alternative 3: HandleErrorPattern/Case dispatch to the first matching case's T, return false when no case matches or err isn't an ErrorPatternResponse at all |
TestPublish_ClientMiddlewareFnError_NeverConsultsErrorChannel (adapters/mqtt5, adapters/zeromq, adapters/mqtt) |
Topic 7 regression guard: confirms the publisher/client role EXCLUSION stays correct — a publish-side middleware Fn error is returned directly, never checked against a declared ErrorChannel, even when one exists on the same channel |
TestSubscribeError_As_ExtractsUnderlyingErrorType (adapters/mqtt5, adapters/zeromq, adapters/mqtt) |
Topic 7: SubscribeError.As(&target) correctly delegates to errors.As on the wrapped Err, mirroring the standard library's own As semantics |
TestDeadLetter_GlobalDefault_Inherited/_OverriddenPerRoute/_OptedOut (api/events, api/reqreply) |
Topic 4: builder-level DeadLetter default is inherited when a route/channel declares none, overridden when one is declared explicitly, and can be opted out of via an explicit empty override (mirrors GlobalSecurity/Security's precedent) |
TestDeadLetter_FailedPublish_Published (adapters/mqtt5, adapters/zeromq, adapters/mqtt) |
Topic 4: a publish-side failure (never reached the broker) is ALSO published to the declared dead-letter topic, alongside the synchronous error the caller receives from Publish(...) |
TestRouteHandle_HasErrorPatterns/TestChannelHandle_HasErrorPatterns (api/rest, api/events, api/reqreply) |
Topic 5: the new accessor correctly reports whether at least one ErrorPattern/ErrorChannel is declared, gating RecordErrorPatternMiss |
TestSpanTagger_TagSpan_CalledOnMatch/TestObserver_NotImplementingSpanTagger_NoPanic (api/rest, api/events, api/reqreply) |
Topic 5: the new stats.SpanTagger extension is called when implemented, and the type-assertion guard prevents a panic when it is not |
Files to create/modify (sketch)¶
| File | Responsibility |
|---|---|
api/rest/builder.go |
Topic 1's reopened fix: new DuplicateErrorStatusError type + Register/RegisterHandle-time rejection of 2+ same-status ErrorPatterns (mirrors reqreply's DuplicateErrorPatternCodeError) |
api/rest/builder_test.go |
Update TestDecodeErrorFor_FirstMatchWins_SameStatus's expected outcome to the new rejection behavior |
docs/features/rest-api.md |
Update the "same-status precedence" callout — now describes a hard rejection, not an accepted ambiguity |
render/asyncapi/v3/document.go |
Operation.Messages []Message field; buildChannelsAndOperations updated to collect from Messages when non-empty |
api/reqreply/builder.go |
Replace the per-error-type separate-channel loop with appending to one reply channel's Messages slice |
api/events/dead_letter.go (new) |
DeadLetter/DeadLetterOpt (WithCode/WithDescription/WithSchemaName/WithChannelAddress/WithOperationID, mirroring reqreply.ErrorPatternOpt)/DeadLetterEnvelope; Builder-level global default + per-channel override/opt-out (mirrors GlobalSecurity/Security); NEW core-layer ChannelHandle.DeadLetterFor(ctx, obs, sourceTopic, rawPayload, err) (topic string, body []byte, ok bool) — builds the envelope + reports "dead_letter" internally, so adapters only supply the raw payload they already have and publish the returned bytes |
api/reqreply/dead_letter.go (new) |
Same, RouteOpt variant + Server-level global default + RouteHandle.DeadLetterFor mirroring ChannelHandle.DeadLetterFor exactly |
adapters/mqtt5, adapters/zeromq, adapters/mqtt |
Per-adapter dispatch wiring for DeadLetter's two-tier fallback (Tier 1: decode-class, unconditional; Tier 2: business-error-class, only after ObserveErrorResponseFor misses) — events subscribe dispatch, events publish dispatch (failed-publish case), and reqreply serve dispatch. Thin per the core-layer consolidation: each site is if topic, body, ok := handle.DeadLetterFor(ctx, obs, handle.Topic, msg.Payload, err); ok { client.Publish(ctx, topic, body) } — zero envelope/encode/observer logic remains adapter-side |
docs/guides/asyncapi.md |
Update "Declaring dedicated req/reply error channels" section for the new multi-message rendering |
docs/guides/error-handling.md |
New "Dead-letter queue" section, cross-linking stream.MapErr/Handler Disposition as related-but-distinct; new "Observing declared error patterns" section for Topic 5, documenting ObserveErrorResponseFor/DeadLetterFor as the recommended single call sites |
stats/observer.go |
New ErrorPatternObserver interface; new, SEPARATE SpanTagger interface (TagSpan(ctx, key, value string) — additive, does NOT modify the existing TraceObserver interface); LoggingObserver/fanout/NoopObserver implementations for both new interfaces |
api/rest/builder.go, api/events/builder.go, api/reqreply/route.go |
New HasErrorPatterns() accessor on RouteHandle/ChannelHandle (one-line len(h.errorPatternRules) > 0 wrapper, called internally by ObserveErrorResponseFor — not adapter-facing); NEW core-layer ObserveErrorResponseFor(ctx, obs, err) (resp, matched, applyErr) wrapping ErrorResponseFor + ErrorPatternObserver/SpanTagger internally |
adapters/nethttp/serve.go, adapters/chi/serve.go |
Topic 1's fix (FULL enumeration, ~11 call sites): body decode, path/query/cookie/header param validation, middleware DecodeIn, security middleware Fn, response merge-field encode, middleware EncodeOut, response body encode — each gains an ObserveErrorResponseFor call (replacing a bare ErrorResponseFor call) before its existing fixed-shape fallback, UNCHANGED when unmatched; Topic 5's match/miss/span-tag observability comes along for free, no separate adapter-side wiring needed |
adapters/mqtt5, adapters/zeromq, adapters/mqtt's adapter.go/binding.go |
Same full-enumeration fix, SUBSCRIBE side only (Topic 7 confirms publish side correctly stays excluded): payload decode, topic/property-var merge, middleware DecodeIn, security middleware Fn — each calls ObserveErrorResponseFor |
adapters/mqtt5, adapters/zeromq's reqreply_transport.go |
Same: request decode, topic/property-var merge, middleware DecodeIn/EncodeOut, security middleware Fn (server-side dispatch only — client-side stays excluded, same Topic 7 reasoning) — each calls ObserveErrorResponseFor |
docs/guides/observer.md |
New rows for RecordErrorPatternMatch/RecordErrorPatternMiss/SpanTagger.TagSpan/"dead_letter" in the observer location-value reference table |
adapters/mqtt5, adapters/zeromq, adapters/mqtt's errors.go |
Topic 7: SubscribeError.As(target any) bool convenience method |
adapters/nethttp, adapters/mqtt5, adapters/zeromq |
Topic 6: ErrorPatternAs[B any](err error) (B, bool) (Alternative 1); errorCase/typedCase[T]/Case[T]/HandleErrorPattern (Alternative 3) |
api/rest/builder.go, api/reqreply/route.go |
Topic 6: ErrorPatternOpt[E, B].Match(err error) (B, bool) (Alternative 2, thin wrapper over Alternative 1) |
.github/instructions/go-codex.instructions.md |
api/rest/api/events/api/reqreply/render/asyncapi/v3/stats rows updated |
Out of scope (this round)¶
- Handler Disposition (ack/nack/requeue) — tracked separately in
docs/design/d-0006-protocol-native-capabilities.md, cross-referenced only. - Extending dead-lettering to matched-but-failed-to-encode cases.
api/mcp/adapters/websocketerror mechanisms.- Changing REST's OpenAPI rendering (no equivalent gap exists).