Retry & error handling
onError, retry policies, retryable status codes, Retry-After.
Cannectors gives you two knobs for failures: retry (try the same
thing again with backoff) and onError (what to do when retries
are exhausted).
onError
Every module accepts an onError key. It picks what happens when the
module fails after retries.
| Value | Behavior |
|---|---|
fail (default) | Stop the pipeline. Exit with code 3. |
skip | Drop the affected record and continue. No trace is left in the data. |
log | Log the error and continue. The record passes through carrying an error marker. |
filters:
- type: mapping
onError: skip # drop records whose mapping fails
mappings: …
- type: http_call
onError: log # keep going, annotate the failures
endpoint: …skip discards the record with nothing left to inspect: you cannot tell
afterwards whether it failed on a 400 that will never succeed or a 503 that
would work on the next run. If you need that distinction — and for anything you
have to acknowledge selectively, you do — use log and route on the marker.
For inputs and outputs, onError: skip is rarely useful — there's no
record to skip yet for an input, and the output is the last stage
anyway. fail and log are the common choices there.
onError and output batches
An output emits one request per record (requestMode: single), one request for
everything (requestMode: batch), or one request per batch of N records
(requestMode: batch with
batchSize). onError is
evaluated per request, so what it skips depends on the mode:
| Mode | fail | skip / log |
|---|---|---|
single | Stop at the failing record; earlier records stay sent. | Drop that record, keep going. |
batch without batchSize | Stop; nothing was sent. | Nothing sent, pipeline continues. |
batch with batchSize | Stop at the failing batch; earlier batches stay applied on the remote side. | Drop that whole batch, keep going with the next ones. |
The last row is the one to weigh: splitting a batch trades atomicity for a
smaller blast radius. A skip there discards every record of the failing batch,
not just the one the destination objected to.
A run that dropped records this way is still a success — skip and log
exist precisely so the pipeline finishes. It is not a silent success though: the
run reports the records it could not send, so a skip that swallowed a batch is
visible in the summary.
✓ Pipeline executed successfully
Status: success
Records processed: 70
Records failed: 50Records that went through in log mode are counted too, broken down by
category — a run where every enrichment failed is a "success" that you still
want to see:
✓ Pipeline executed successfully
Status: success
Records processed: 120
Error markers: 12
server: 9
validation: 3The total counts markers, not records: a record that failed an enrichment call and then a persistence call appears in both categories, because both failures happened.
Each marker is counted where it is created, so the count survives whatever the
pipeline does with the record afterwards. Routing the rejects out — with drop,
or a condition branch that does not forward them — is the point of the marker,
and it does not erase the failure that made you route it. Records processed
counts what reached the output; Error markers counts what went wrong.
Error markers
A module running with onError: log annotates the record it could not process,
under the reserved key _errors. The record continues down the pipeline
carrying what went wrong, so later filters and outputs can act on it.
{
"id": "42",
"_errors": [
{
"module": "http_call",
"category": "validation",
"retryable": false,
"code": "HTTP_CALL_HTTP_ERROR",
"statusCode": 400,
"message": "http_call HTTP error 400: …",
"attempts": 1,
"recordIndex": 3
}
]
}Fields
| Field | Meaning |
|---|---|
module | Type of the module that failed. |
category | network, authentication, validation, rate_limit, server, not_found, unknown. |
retryable | Whether the failure is transient. This is the technical / functional split. |
code | Module-specific error code, when the module defines one. |
statusCode | Protocol status code. Absent when the failure carries none — a connection timeout has no status. |
message | What happened, with URLs stripped of query strings, fragments and credentials. |
attempts | Attempts performed before giving up. Absent when the module does not retry. |
recordIndex | The record's position in the batch handed to the module. |
_errors is a list: a record can fail in several filters along the chain,
and each failure appends its own marker. The first one is usually the root
cause.
Default classification
| Situation | category | retryable |
|---|---|---|
400, 422, other 4xx | validation | false |
401, 403 | authentication | false |
404 | not_found | false |
429 | rate_limit | true |
5xx | server | true |
| Timeout, DNS, connection refused, TLS | network | true |
| Mapping / transform failure | validation | false |
| Response body the module cannot parse | validation | false |
| SQL constraint violation, SQL syntax error | validation | false |
| SQL deadlock | server | true |
| Lost or refused database connection, query timeout | network | true |
SOAP fault, Client / Sender code | validation | false |
SOAP fault, Server / Receiver code | server | true |
A SOAP fault is classified on its fault code, not on the HTTP status: SOAP
1.1 mandates 500 for every fault, so a malformed request and an unavailable
backend arrive with the same status. A fault code that is neither of the two
above is recorded as non-retryable — the service answered, and replaying a
request it already rejected is what the classification exists to prevent.
A status code only appears in the marker when it caused the failure. A 200
whose body turns out to be unparseable leaves a marker without statusCode,
so a _errors[0].statusCode >= 400 condition never mistakes it for a success.
Routing on a marker
- type: condition
expression: "_errors != nil and _errors[0].retryable == false"
then:
- type: set
target: routing.status
value: REJECTEDWrite _errors != nil as the guard. The key is absent from records that
never failed, and the expression language rejects len(nil). So
len(_errors) > 0 alone raises a runtime error on the first healthy record —
use _errors != nil and len(_errors) > 0.
Overriding the classification
Some APIs answer 404 for "no match", which is an ordinary outcome rather than
an anomaly. errorClassification restates what a status code means for one
module:
- type: http_call
onError: log
endpoint: https://profiles.example.com/api/customers/{customerId}
errorClassification:
functional: [404] # record as not retryable
technical: [409] # record as retryableAn override decides retryable. The category is kept whenever it does not
contradict that verdict: a 404 declared functional stays not_found, it does
not flatten into validation. It is only rewritten when the two disagree — a
503 declared functional becomes validation, a 409 declared technical
becomes server. A code listed in both lists is treated as functional.
errorClassification does not decide whether a request is replayed — that stays
with retry.retryableStatusCodes. The reverse is not true: when
retryableStatusCodes makes the runtime replay a code the defaults would not
have, the marker reports the retryability the runtime actually applied. The
marker describes what happened, not what the defaults would have said.
Reserved key
_errors belongs to the runtime. Every filter that writes a record field
refuses a target that reaches into it, and the pipeline fails to start:
- type: set
target: _errors # rejected: reserved for the runtime's error markers
value: []
- type: sql_call
mergeStrategy: append
resultKey: _errors # rejected for the same reasonThat covers set and mapping through their target, and http_call,
soap_call and sql_call through their resultKey. A field further down a path is your own
data and stays writable — payload._errors is fine.
This one is a startup check, not a schema check: cannectors validate
accepts a reserved target, and cannectors run refuses to build the filter.
Unlike append without resultKey above, it will not be caught by a
validate-only CI gate.
Two filters are deliberate exceptions:
| Filter | Why |
|---|---|
remove | remove: _errors is the supported way to strip the markers before an output, so the internal error messages they carry never reach the destination API. |
script | A script receives the whole record and can rewrite any key, _errors included. Nothing enforces the reservation there. |
Both are escape hatches. Using them changes what reaches the output — it does not change the summary counters, which count each marker where it is created.
Retry block
For HTTP-shaped and SOAP-shaped modules (httpPolling, http_call,
httpRequest, soapPolling, soap_call, soapRequest), the retry
block defines how the module retries before giving up and applying
onError.
retry:
maxAttempts: 3 # total attempts, including the first
delayMs: 500 # initial backoff
backoffMultiplier: 2 # exponential factor
maxDelayMs: 5000 # cap on the backoff
retryableStatusCodes:
- 429
- 500
- 502
- 503
- 504
useRetryAfterHeader: trueFields
| Field | Default | Meaning |
|---|---|---|
maxAttempts | 1 | Total attempts. 1 means "no retry". |
delayMs | 1000 | Backoff before the first retry. |
backoffMultiplier | 2 | Multiplied into the delay for each subsequent retry. |
maxDelayMs | 10000 | Upper bound on the computed delay. |
retryableStatusCodes | [429, 500, 502, 503, 504] | HTTP statuses considered retryable. |
useRetryAfterHeader | true | If the server sends Retry-After, honor it. |
What gets retried
- HTTP status codes in
retryableStatusCodes. - Transport errors (TCP refused, DNS, TLS handshake failures, read timeouts).
429 Too Many Requestswith aRetry-Afterheader — Cannectors sleeps for the indicated duration before retrying, ignoring the computed backoff.
Everything else fails immediately, no retry.
Defaults inheritance
Drop a retry block into top-level defaults and every HTTP-shaped
module inherits it. Module-level retry blocks merge field-by-field with
the default: declared module fields override the default, while omitted
fields keep the inherited value. See
Defaults inheritance for the exact
rules.
defaults:
onError: log
retry:
maxAttempts: 3
delayMs: 500
backoffMultiplier: 2
retryableStatusCodes: [429, 500, 502, 503, 504]What "fail" actually does
When a module fails with onError: fail (or is left as default and
fails), Cannectors:
- Logs the error at ERROR level with context (pipeline, module type, record index, root cause).
- Stops the pipeline immediately. No further records are fetched or processed.
- Exits the process with code 3 (
runtime errors). For scheduled pipelines, the process exits — your supervisor decides whether to restart.
Exit codes
| Code | Meaning |
|---|---|
0 | Success |
1 | Validation errors |
2 | Parse errors |
3 | Runtime errors |
validate produces 0, 1, or 2. run adds 3 to that.