Handling failures
A ## Fault: section is the flow’s error handler. Any unhandled failure from the main step
sequence runs the fault steps instead of propagating, and what they produce becomes the response.
The one exception is a delivery that lost its claim on its deduplication key: see A lost claim
skips the handler.
---flowmarkdown_version: "0.1"flow: order-intaketenant: acmeeffects: [http_egress]---
## Step: check```validate$.orderId != null | "orderId is required" | syntactic```
## Step: forwardeffects: [http_egress]endpoint: https://orders.example.com/ingest
## Fault: reportresponse_status: 400response_header_content_type: application/problem+json```ntd{ "title": "Order rejected", "detail": "the order could not be processed", "step": "{{ $.fault_step }}", "correlationId": "{{ $.fault_correlation_id }}"}```A flow may have several ## Fault: sections. They run in order, as one sequence, exactly like the
main steps.
A fault section may hold any of the five composition fences: include, call_flow, foreach,
split and fork (see Composing flows). Each runs as it does in
the main steps, and takes only the library flow’s main steps; the library flow may not have a
## Fault: section of its own (see
A library flow has no handler). In a
fault section $ is first the description of the failure (below), so the iterator of a foreach
or split there names an array the handler can read: the output of an earlier fault step, through
$, or a variable the main steps wrote, through ctx.<name>, such as a request kept with
save_body.
What the handler sees
ctx.input is replaced with a description of the failure:
| Field | Contents |
|---|---|
$.fault_step |
Name of the step that failed |
$.fault_error |
The error as the operator sees it. It carries, for example, the step, the downstream address of a failed connection, HTTP <status> for an HTTP answer that is not 2xx (whose body is in ctx.HTTP_RESPONSE_BODY), the code of a failed gRPC call, and up to 512 characters of an OAuth2 token endpoint’s error body; an error of the flow-state store names the flow and carries the store’s own message, and a correlation refusal carries its own text. It is internal, for the log and not for the caller: copied into a response body or ctx.soap_fault_string, all of it reaches the caller. A secret_read whose variable is missing names the key, never the variable (What a flow may read). After a validate failure it carries the second form shown in Without a handler: the step, the rule’s kind and its message, each {{ }} part written as the type of its value, never the value |
$.fault_correlation_id |
Correlation ID of this execution |
The original message is gone unless you kept a copy. Use save_body in an early step if the fault
handler needs the request:
## Step: keepsave_body: request```ntd{{ $ }}```
## Fault: report```ntd{ "title": "Order rejected", "orderId": "{{ ctx.request.orderId }}", "detail": "the order could not be processed", "correlationId": "{{ $.fault_correlation_id }}"}```Variables written by the main sequence survive into the handler, so anything you stored is
readable as ctx.<name>. That includes the ones the platform wrote for you: after a failed
http_egress call, ctx.HTTP_RESPONSE_BODY holds the backend’s error body, parsed, and
ctx.HTTP_RESPONSE_HEADERS its response headers — so a handler can turn a partner’s own error
document into the answer your caller gets. See
Outbound HTTP calls.
A condition fence inside a fault handler does not mark the original message as filtered — the
two are kept separate.
The handler does not recurse
A failure inside the fault sequence is not caught by the fault sequence. It produces a handler failure and a 500. Keep fault steps simple: a template and, at most, one well-bounded call.
A library flow’s failures
A failure in the steps a flow takes from a library flow by include, call_flow, foreach or
split is handled by the ## Fault: section of the calling flow, as a failure in its own steps.
A fork branch’s failure does not reach the caller. The library flow itself may not have a
## Fault: section: publication refuses a flow that reaches one by any of the five fences. See
A library flow has no handler for
the refusal and for where the handler goes.
A lost claim skips the handler
A flow with dedup_key: holds a claim on its key while it runs and checks it before each step.
The claim is lost when an operator releases it with
nexus state release, or when the platform could not
confirm it for two thirds of flow_lease_ttl_secs. The delivery then stops before its next step,
and the fault steps do not run: they are steps of the flow, and running them for a key this
delivery no longer holds would be the second execution the stop is there to prevent. A fault
sequence already running stops the same way. The step in progress when the claim was lost runs to
its end; nothing cancels a step.
The caller gets 503, with the step that did not start, and nothing is recorded for the key. The
next delivery of the key runs the message from its first step, or is held back or replayed as
Deduplication says if another delivery has claimed
it since. On the queue the message is retried, even under dedup_on_failure: absorb.
The response status
A successful fault handler does not return 200. It returns the status the caller would have received had there been no handler at all:
| Failure | Status |
|---|---|
validate rule with syntactic |
400 |
validate rule with semantic |
422 |
dedup_key: produced nothing from this message, or could not be read from it — a field read through a value of another shape, such as a body that is an array |
400 |
| A correlation is already open for the same key | 409 |
| A correlation was refused — nothing open, already settled, state too large, key not formed or not readable from the reply | 422 |
| An effect failed — a call out that did not succeed | 502 |
Flow state is unavailable — the platform cannot honour dedup: or correlation: right now |
503 |
The delivery lost its claim on its dedup: key — released with nexus state release, or not confirmed in time; the fault steps do not run |
503 |
| Anything else — a defect in the flow, a branch that timed out, the duration ceiling, a failed handler | 500 |
502 for an outbound failure is deliberate: the platform is acting as a gateway, and a downstream system being unavailable is not the caller’s fault. 500 for a defect in the flow is deliberate in the other direction: the caller’s message was fine, so telling it 4xx would send it looking for a fault of its own.
That table is the whole mapping, and there is only one of it. The same list answers for a flow with a handler and a flow without one, for the JSON door, for the queue and cron records, for SOAP 1.2, and for the number the gRPC door writes to the log. There is no second table anywhere to drift away from it.
Overriding the status
response_status: sets the status explicitly. It is a step key like any other and works on any
step, not only inside a ## Fault: section — a flow that succeeds can answer 201 just as well.
## Fault: reportresponse_status: 409The value may be a template, which is how a flow passes a backend’s own status through to the caller:
## Step: proxyresponse_status: "{{ ctx.HTTP_SC }}"Two things about that template are easy to get wrong and are therefore worth stating. Inside it,
$ is the step’s output, not its input — the status is collected after the step returned.
And a template that renders something which is not a status is a step error, loudly: in the main
sequence it triggers the ## Fault: section, and inside a fault step it becomes
FaultHandlerFailed → 500.
A literal outside 100–599 is a compile error, so nexus validate catches it, not just nexus deploy.
A declared status is collected only from steps that actually ran, in execution order, so a
route takes the status of the branch that was taken and a step that failed contributed nothing.
It does not survive entry into ## Fault:. On the way in, the status becomes the natural
status of the error that triggered the sequence, and the fault steps may set their own from there.
That asymmetry with headers is deliberate: the status is the verdict, and a response_status: 201
declared before the error would describe a result that did not happen.
To get the older behaviour where a successful handler answers 200, say so:
## Fault: absorbresponse_status: 200That changes the status on the wire, and nothing else. The delivery still ended on the fault
sequence, so a flow with dedup_key: commits its row as a failure either way, and the next delivery
of the same key runs again instead of receiving this answer. If what you want is a stable answer to
a duplicate of a failed delivery, the key for that is dedup_on_failure: absorb — see
The flow file.
fault_status: is the deprecated spelling of the same key, still accepted inside ## Fault:
sections and refused anywhere else. It is one mechanism with two names, not two mechanisms.
Response headers
response_header_<name>: sets a response header. Like the status, it works on any step, and
the set is collected from the steps that ran. The suffix becomes the header name: underscores
become hyphens and each segment is capitalised.
| Written | Sent |
|---|---|
response_header_content_type: application/problem+json |
Content-Type: application/problem+json |
response_header_retry_after: 60 |
Retry-After: 60 |
response_header_x_trace_note: abc123 |
X-Trace-Note: abc123 |
response_header_remove: [x-internal, x-ephemeral] takes names back out of the accumulated set.
Both keys are order-sensitive by construction: last writer wins per name, case-insensitively, and a
response_header_x: written after a removal puts it back.
Values are literal — there is no NTD interpolation here.
Unlike the status, headers declared before the error do survive into the fault sequence: they are decoration, not the verdict.
Setting response_header_content_type: is also how a flow overrides the content type that would
otherwise be derived from the shape of its output — see The response
body.
Some names are refused at compile time: the nine hop-by-hop and framing headers (host,
content-length, transfer-encoding, connection, and the rest — the same list that guards
outbound headers:), traceparent and tracestate, and the whole x-nexus- prefix, which is reserved for the platform’s own
assertions about a delivery. A flow able to write x-nexus-idempotent-replay could claim a
response came from a recorded execution when it did not.
fault_header_<name>: is the deprecated spelling, again confined to ## Fault: sections.
A worked example
Distinguish a rejected request from an unavailable downstream, and tell the caller when to retry:
---flowmarkdown_version: "0.1"flow: order-intaketenant: acmeeffects: [http_egress]---
## Step: keepsave_body: request```ntd{{ $ }}```
## Step: check```validate$.orderId != null | "orderId is required" | syntactic$.total > 0 | "total must be positive"```
## Step: forwardeffects: [http_egress]endpoint: https://orders.example.com/ingestmethod: POSTread_timeout_ms: 5000
## Fault: problem-detailresponse_header_content_type: application/problem+jsonresponse_header_retry_after: 30```ntd{ "type": "https://errors.example.com/order-intake", "title": "Order could not be accepted", "detail": "the order could not be accepted now; quote the correlation id when you ask why", "step": "{{ $.fault_step }}", "orderId": "{{ ctx.request.orderId }}", "correlationId": "{{ $.fault_correlation_id }}"}```With no response_status:, each failure answers with the status from the table above — 400 or 422
for a validation rule, 502 for a downstream failure — each with the same problem-detail body and the
same headers.
$ curl -i -X POST http://localhost:9090/flows/acme/order-intake/run \ -H "Authorization: Bearer $NEXUS_KEY" \ -H 'Content-Type: application/json' -d '{"total":0}'
HTTP/1.1 400 Bad RequestContent-Type: application/problem+jsonRetry-After: 30{"type":"https://errors.example.com/order-intake","title":"Order could not be accepted", ...}Without a handler
A flow with no ## Fault: section returns the error directly, with the same status as the table
above — a handler changes the body, not the number.
A validate failure keeps its message, which is yours and reaches the caller verbatim:
HTTP/1.1 400 Bad Request{"ok":false,"error":"orderId is required","step":"check"}Only the answer to the caller carries the message with its values filled in. The operational log
and the L1 event’s error carry the same message with your
text as written and each {{ }} part written as the type of its value between the same braces:
{{Null}}, {{Bool}}, {{Number}}, {{String}}, {{Array}}, {{Object}} or {{Xml}}. A rule
$.orderId != null | "order {{ $.ref }} has no orderId" | syntactic over {"ref": "A-17"} answers
the caller order A-17 has no orderId. The server log line reads the first line below, and the L1
event’s error the second:
validate fence rejected input: order {{String}} has no orderIdstep 'check': validation failed (Syntactic): order {{String}} has no orderIdThe second form is also what a queued message’s DLQ row and the tenant’s audit chain record, what a
scheduled run logs, and what a fault handler reads in $.fault_error. To find the value a request
was refused for, send the request again or, when the flow’s log_level is payload_trimmed or
full, read the payload L1 stores with the failure event: $ as it stood when the step failed,
which is the request unless an earlier step replaced $. Masked fields stay masked there. A $
that holds XML is not stored, and once a ## Fault: section has run, L1 holds that section’s $,
not the request.
Because the caller reads the values, the message must not read a credential the platform put into
the context: a name a secret_read step writes, or
ctx.oauth2_token. The same refusal covers ctx.headers.authorization and
ctx.headers['Authorization'], although that header never reaches the flow. Such a message is
refused at publication, by nexus validate as well as nexus deploy. In a flow whose
secret_read step writes api_key, a rule message that reads ctx.api_key gives:
$ nexus validate order-intake.flow.mdorder-intake.flow.md:22:1: error: step 'check': invalid configuration — validate rule message reads `ctx.api_key`, which the platform treats as a credential. The message is sent to the caller, so it never carries a credential (ADR-0072). Already published artifacts are untouched; this refusal applies at (re)publication onlyerror: 1 validation error(s)The check reads every main and fault step of the file being published and of each library flow it
reaches by include, call_flow, foreach, split or fork. A message in any of these files
must not read a name that a secret_read step in any of them writes. When the message is in a
library, the line starts with the library and has no position: error: library flow '<library>': step '<step>': invalid configuration — …
Not refused: a field of the request, masked or not, because the caller sent it; a value the flow
copied into $ before the message; a call: parameter, which the library reads as ctx.<name>,
unless that read is one refused above. The caller receives these values.
Any other failure of the step sequence — a failed call out, a refused encoding, a template that could not be evaluated — keeps its status and loses its message:
HTTP/1.1 502 Bad Gateway{"ok":false,"error":"flow execution failed"}Not the step’s own message: without a handler there is nothing that decides what a caller may be
told, so nothing is told. The detail goes to the operational log, naming the flow and the step. So
what a handler buys you is the body — a problem-detail document, your own wording, the fields
your caller can act on — and the freedom to answer a different status on purpose with
response_status:.
Until 2026-09-26 this was not true: without a handler every failure but a validate rule answered
422, whatever it was, so a downstream outage and a defect in the flow arrived as the same number,
and that number said the caller’s message was at fault. If you have a client that treats 422 as
“the failure was mine”, it now sees 502 for a downstream failure and 500 for a defect in the
flow. Some answers carry a phrase of their own, the same on JSON, SOAP and gRPC, because a caller
must not misread them. The statuses below are those of JSON and SOAP 1.2; SOAP 1.1 answers 500
whatever the error (SOAP), and gRPC answers a code of its own
(gRPC):
- exceeding the duration ceiling is
500withflow exceeded its maximum duration; on JSON the body also names, instep, the step that did not start; - a fault handler that itself fails is
500withfault handler failed; - flow state the platform cannot read or write is
503withflow state unavailable: this flow cannot be delivered right now; - a delivery that lost its claim on its deduplication key is
503on JSON and aServerorReceiverfault on SOAP, withflow execution stopped before it finished; some of its steps may have run; on JSON the body also names, instep, the step that did not start; - a
validaterule whose message renders empty answersvalidate rule failed.
A platform error that is none of the flow’s doing is 500 with internal error on JSON and SOAP;
gRPC answers it internal with flow execution failed.
The downstream system’s own error text never travels in any of these — that stays in the
operational log, since it is written by another party. Inside a handler you can read it from
ctx.HTTP_RESPONSE_BODY and decide for yourself what the caller sees.
On a queue, a handler acks the message
Everything above describes a caller waiting for an answer. On the queue door there is no caller, and the same handler has a second effect that is easy to miss.
A handler that succeeds becomes the delivery’s answer, so the delivery ends in success — and the
queue worker acks the message. No retry, no backoff, no dead letter, whatever
queue_max_attempts: says. The row in the log is queue_processed, and the only thing separating
it from a real success is the outcome column, which reads fault_handled.
That is the right behaviour when the handler is the policy: it recorded the rejection, notified
somebody, and the message is finished. It is the wrong behaviour when the handler was written for
the synchronous door and the flow later grew a queue_max_attempts:. Then the dead-letter queue is
silently disarmed: the messages you expected to find parked for a human were acked and are gone.
If you want both — a handled answer and the retry — the handler has to fail. End it with a step
that errors, and the sequence returns FaultHandlerFailed: nothing produced an output, the
delivery is failed again, and the queue retries and dead-letters exactly as it would with no
handler at all.
## Fault: notify-then-give-upeffects: [http_egress]endpoint: https://alerts.example.com/notifymethod: POST
## Fault: decline```validate$.fault_step == "never" | "handled, but still a failed delivery"```The notification is sent, and the message still goes through its retries to the dead-letter queue.
Two things that do not change: ## Fault: is a property of the flow, not of the door, so the
same section runs on /run, on the queue, on cron and on gRPC; and a handled fault has always
counted as a failed delivery for deduplication, so a flow with dedup_key: commits its row as a
failure, and a duplicate of the key runs again unless the flow sets dedup_on_failure: absorb.
See the queue for what that means for retries.