Repository navigation
[Discussion]Configurable error handling and fault sequences for the API Platform gateway #3213
Replies: 6 comments 1 reply
Open questionsA. Naming and configuration shape
B. The canonical error model
C. What error shaping is allowed to do
D. Scope of the first release
E. Follow-on work to schedule
F. Other
|
Meeting Notes 17/08/2026
type ImmediateResponse struct {
StatusCode int
Headers map[string]string
Body []byte
IsFault bool //NEW
Error *GatewayError // NEW
...
}
|
Meeting Notes 18/08/2026
spec:
faultPolicies:
- name: log-message
version: v1
- name: interceptor-service
version: v1
params:
error:
url: https://siem.internal.example/gateway-faults
- name: set-headers
version: v1
params:
error:
headers:
- name: x-fault-ref
value: contact-support
type ImmediateResponse struct {
StatusCode int
Headers map[string]string
Body []byte
IsFault *bool //NEW
Error ErrorResponse // NEW
...
}
// Unified error response for gateway generated errors
type ErrorResponse struct {
Code string // stable machine identifier
Type string // authentication | authorization | throttling |
// guardrail | upstream | routing
Direction string // Request | Response
Message string // short human summary, always safe to return
Description string // longer detail — MAY be withheld
Policy string // which policy failed; empty = no policy did
Guardrail GuardrailError // guardrail specific info
}
|
Meeting Notes 04/09/2026
|
UpdateAdded Configurations[policy_engine.fault_policies]
handle_upstream_faults = false # default
Management API Changes
example: apiVersion: gateway.api-platform.wso2.com/v1
kind: RestApi
metadata:
name: orders
spec:
displayName: orders
version: v1.0
context: /orders/$version
upstream:
main:
url: https://orders.internal
operations:
- method: GET
path: /orders/{id}
faultPolicies: # operation level: runs FIRST
- name: log-message
version: v1
executionCondition: 'fault.Status >= 500'
params: { fault: { payload: true } }
policies:
- name: api-key-auth
version: v1
params: { key: X-API-Key, in: header }
faultPolicies: # API level: runs after the operation's entries
- name: error-response-formatter
version: v1
executionCondition: 'fault.Source == "gateway"'
params:
template:
error: { code: ${Code}, type: ${Type}, message: ${Message} }
requestId: ${RequestID}
status: ${Status}SDK ChangesError formatter
Analytics Changes
"errorType": "OTHER",
"error": {
"errorCode": 422,
"errorMessage": "UNCLASSIFIED"
}
"errorType": "OTHER",
"error": {
"errorCode": 422, // unchanged: the HTTP status
"errorMessage": "UNCLASSIFIED", // unchanged: from the status
"wso2ErrorCode": 906201, // NEW: the fault code
"type": "guardrail",
"direction": "Response",
"summary": "Violation of applied word count constraints detected",
"policy": "word-count-guardrail",
"policyPhase": "response_body",
"source": "gateway",
"originalStatus": 200,
"guardrail": {
"name": "word-count-guardrail",
"action": "GUARDRAIL_INTERVENED",
"reason": "Violation of applied word count constraints detected"
}
}Changes to the shipped policies
The error-response-formatter policy
Placeholders: ${Code} ${Type} ${Direction} ${Message} ${Description} ${Policy} ${PolicyPhase} ${Source} ${Status} ${RequestID} ${OpenAIType} ${JsonRpcCode} ${JsonRpcID} ${Soap11Code} ${Soap12Code}. References |
GraphQL error shaping with faultformatGraphQLApi routes have the same problem this feature fixes for A2A. A GraphQL client expects {"errors":[{"message", "extensions"}]}, but gateway errors on those routes come back as {"error": ...}. That covers rejections from jwt-auth, api-key-auth and rate limiting, the engine's 500s, and router failures. Only graphql-authz (new policy added via wso2/gateway-controllers#316) uses the GraphQL envelope, and it carries no code. I'd like to add GraphQL to the default formatter. Some of this touches PR #3622 and the SDK, so raising it here first. 1. Enable formatting for GraphQLApi (PR 3622)
2. Wire format: the extensions contents
The mapping needs no SDK change. The alternative is to put the Apollo name in code and the gateway code under another key, which suits GraphQL clients but breaks consistency with the other shapes.
|
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
1. Problem
Two distinct problems share one trigger point.
They share a trigger — "a request failed" — and nothing else:
Two different defaults, two different configurability answers. They are separate
components with separate switches.
2. Error sources
Four sources. Which one produced an error decides both whether an operator cares and whether the engine can even see it.
flowchart LR C([Client]) --> E[Envoy router] E -->|ext_proc| P[Policy engine] P --> E E --> U([Upstream]) A["<b>A · gateway</b><br/>policy / guardrail<br/>rejection"]:::gw B["<b>B · backend</b><br/>upstream returned<br/>an error status"]:::be R["<b>C · router</b><br/>no healthy host,<br/>timeout, conn refused"]:::rt N["<b>D · no route</b><br/>matched no API"]:::nr P -.produces.-> A U -.produces.-> B E -.produces.-> R E -.produces.-> Nno healthy upstream, connect timeoutSource D does not, and cannot without a config change. The catch-all route explicitly
disables ext_proc (
translator.go:846). See §13.3. Solutions
Option 1 — Declarative error-response configuration (Initial approach - rejected)
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.
OR
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 — A new SDK
OnError()hookA dedicated interface implemented by fault-specific policies.
For: type-safe. A fault policy could not be mis-configured into the normal chain.
Against:
interceptor-service,log-message,respondand every guardrail work on the fault path unchanged; withOnErroreach would need re-implementing.(no chain is selected), streaming is blocked by HTTP rather than by the interface.
Decision: not as a replacement. If the type-safety argument wins later, add it as an
optional additional interface — checked first, falling back to the response hooks — never
instead of them.
All reactions