[Discussion]Configurable error handling and fault sequences for the API Platform gateway #3213
SavinduDimal
started this conversation in
Ideas
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Problem
When a policy or guardrail rejects a request, the gateway returns a status code and nothing else happens. A guardrail rejection produces a 422, an authentication failure a 401, and the interaction ends there.
Two distinct needs come out of this, and separating them is what makes the options below tractable:
Both are in scope. They are complementary rather than alternatives, and the options below are grouped accordingly.
Error sources
Any design here is constrained by where the error originates, because the four sources are handled at different layers of the gateway.
flowchart TB Client(["Client"]) --> Match{"Route matched?"} Match -- "no" --> D["SOURCE D — no route<br/>404, body determined at deploy time"] Match -- "yes" --> Chain{"Policy chain outcome"} Chain -- "rejects" --> A["SOURCE A — gateway/policy error<br/>401 / 403 / 422 / 429 / 500"] Chain -- "continues" --> Up["Router → upstream"] Up -- "unreachable / timeout" --> C["SOURCE C — router local reply<br/>503 / 504, body determined at deploy time"] Up -- "4xx / 5xx" --> B["SOURCE B — backend error"]Sources A and B pass through the policy engine on every request, so per-request values are available and policy code can run. Sources C and D never reach the engine: the router generates those responses itself from a body determined when the configuration was deployed, and no policy chain is selected for an unmatched route. That distinction determines what each option can and cannot cover.
Option 1 — Declarative error-response configuration
Addresses need 1. A global document plus a per-API section, both shaped like an OpenAPI Responses Object, customising the body, media type and status code per error status.
The property relevant to the options below is that this is a renderer: it resolves an entry, negotiates the media type, substitutes placeholders and replaces the body. It performs no I/O. For sources C and D the body is produced when configuration is deployed rather than per request, which is why request-scoped placeholders such as
${requestId}cannot carry values for those sources. The option therefore addresses need 1 across all four sources, and does not address need 2.Option 2 — Fault sequence (proposed for need 2)
A per-API, ordered list of policies that executes only when a request fails.
The sequence executes over the error response, using the same contracts as a response-phase policy. The consequence is that no fault-specific policy type is required and the existing policy catalogue works on the fault path unchanged. Entries are validated with the same names, versions and parameter schemas as normal policies.
Which function an entry is invoked through is resolved per entry, because the catalogue is split between the two response contracts:
OnResponseHeadersset-headers,remove-headers,cors,basic-ratelimit,mcp-ratelimitOnResponseBodyinterceptor-service, and every response guardrail (word-count,regex,content-length,url, ...)log-message,advanced-ratelimit,token-based-ratelimitAn entry is dispatched through the body contract when it implements one, and through the header contract otherwise. This is not a detail: a sequence supporting only one contract would silently skip roughly half the catalogue — including
interceptor-service, the policy that most directly satisfies "notify a third party" — with no error raised, because a policy that does not implement the expected contract is simply never called.The body contract is preferred where available because it is a superset: it carries the error body, so an entry can transform the error (wrap it, lift fields out of it) rather than only replace it. Entries execute one at a time so declaration order holds across a sequence mixing both kinds.
Trigger points
A single trigger cannot cover the errors the gateway produces, so there are three.
flowchart TB Chain["Request policy chain"] -- "policy rejects the request" --> SC["Gateway builds the error response"] SC --> T1["T1 — the gateway generated the error"] Chain -- "continues" --> Resp["Response header phase"] Resp --> Body["Response BODY phase"] Body --> Q{"who produced the error?"} Q -- "a response-body policy<br/>overrode the status" --> T3["T3 — response-body rejection"] Q -- "backend or router,<br/>passing through" --> T2["T2 — pass-through error"] Q -- "no error" --> Ok["normal response — sequence never runs"] T1 --> FS["FAULT SEQUENCE, in declaration order"] T2 --> FS T3 --> FST1 is required because the response phase is not reached once a policy has rejected a request; without it a guardrail 422 on the request path would never enter the sequence.
T2 covers errors the gateway did not generate — a backend error passing through, or a router local reply (503/504), which does traverse the external processor.
T3 covers a response-body policy rejecting a response. It needs its own trigger because such a policy rejects by overriding the status rather than short-circuiting the chain, which is what T1 detects; and the override happens after the response-header phase, where the status was still the upstream's success value. Response guardrails reject exactly this way, so without T3 a guardrail rejecting a response would be invisible.
T2 and T3 both run in the response-body phase, so body-contract entries receive the error body. They are distinguished by who produced the error, not by where they run — and that distinction is what keeps the threshold rule below correct. Running the sequence in the body phase requires response-body processing to be enabled for the route, so attaching a fault sequence enables it; otherwise the sequence would never fire for an API whose own policies do not need the body.
Under T2, an entry with no
executionConditionruns only at status 500 and above. A 404 for a missing record is routine REST traffic rather than a fault, and firing on all of them would make the feature noisy from the first deployment; an entry opts into 4xx explicitly. T1 and T3 are exempt, since an error the gateway generated is a fault by construction — and a guardrail rejection is a 4xx.What a fault policy is told
Beyond the error itself, each entry receives fault metadata: the trigger, the phase, the status, the upstream's status before any policy changed it, and which policy caused the failure. That last field is what turns a notification from "a 422 happened" into "word-count-guardrail rejected this response".
Attribution is deliberately absent under T2: no policy causes a backend error or a router local reply, and naming one would be misleading — the trigger already identifies the kind of error.
Coverage: sources A, B and C. Source D is not covered, because no API matched and therefore no per-API sequence can be selected.
Behaviour
policies, so they cannot execute in the normal request or response phases.respondpolicy, for instance, cannot be used in a fault sequence.Option 3 — Fault events through the access-log pipeline
Also addresses need 2, by a different route: emit a fault event from the existing analytics pipeline and add a configurable webhook publisher alongside the existing publishers.
Because that pipeline is driven by router access logs rather than by the policy engine, it is the only mechanism that observes all four sources, including the no-route 404 — and also the only one that cannot identify which policy failed without additional metadata being attached. It is global rather than per-API, runs after the response has been sent, and does not execute user-supplied logic.
That makes it complementary to Option 2 rather than a replacement: Option 2 for per-API logic that may influence the response, Option 3 for uniform fault telemetry across every source.
Option 4 — Extend the error-response document with side effects
Adding an
onFailureblock to Option 1's document is the obvious shortcut, and is not recommended. That renderer currently cannot fail; giving it an outbound call would make every error response inherit a timeout, a retry policy and a new failure mode, on the path that is already degraded. For sources C and D the body is produced at deployment time, so there is no per-request execution to attach a side effect to.Comparison
Source A is split into three rows deliberately. It is not uniformly covered by Option 2, and the distinction is not visible from the outside: a request-phase rejection and a response-body rejection reach the sequence by different triggers, and an error raised mid-stream cannot reach it at all.
Open questions
policies, where it would fire on every successful response instead of only on failures. A dedicatedOnErrorcontract would make "faults only" a guarantee rather than a convention — but adopting it as the only contract would forfeit reuse of the existing catalogue, which is Option 2's main advantage. An optional additional contract, checked before the two response contracts, would give the guarantee without that cost.All reactions