Skip to content

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.

---
flowmarkdown_version: "0.1"
flow: order-intake
tenant: acme
effects: [http_egress]
---
## Step: check
```validate
$.orderId != null | "orderId is required" | syntactic
```
## Step: forward
effects: [http_egress]
endpoint: https://orders.example.com/ingest
## Fault: report
response_status: 400
response_header_content_type: application/problem+json
```ntd
{
"title": "Order rejected",
"detail": "{{ $.fault_error }}",
"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.

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 text
$.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: keep
save_body: request
```ntd
{{ $ }}
```
## Fault: report
```ntd
{
"title": "Order rejected",
"orderId": "{{ ctx.request.orderId }}",
"detail": "{{ $.fault_error }}"
}
```

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 HTTP requests.

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.

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 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 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
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: report
response_status: 409

The value may be a template, which is how a flow passes a backend’s own status through to the caller:

## Step: proxy
response_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: absorb
response_status: 200

That 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_correlation_id: abc123 X-Correlation-Id: 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:), 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-intake
tenant: acme
effects: [http_egress]
---
## Step: keep
save_body: request
```ntd
{{ $ }}
```
## Step: check
```validate
$.orderId != null | "orderId is required" | syntactic
$.total > 0 | "total must be positive"
```
## Step: forward
effects: [http_egress]
endpoint: https://orders.example.com/ingest
method: POST
read_timeout_ms: 5000
## Fault: problem-detail
response_header_content_type: application/problem+json
response_header_retry_after: 30
```ntd
{
"type": "https://errors.example.com/order-intake",
"title": "Order could not be accepted",
"detail": "{{ $.fault_error }}",
"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.

Terminal window
$ 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 Request
Content-Type: application/problem+json
Retry-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:

Terminal window
HTTP/1.1 400 Bad Request
{"error":"orderId is required","step":"check"}

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:

Terminal window
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. Three answers keep their own shape, because a caller must not misread them: exceeding the duration ceiling is 500 naming the step that did not start, a fault handler that itself fails is 500 with fault handler failed, and a platform error that is none of the flow’s doing is 500 with internal error.

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-up
effects: [http_egress]
endpoint: https://alerts.example.com/notify
method: 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. See the queue for what that means for retries.