Retries & timeouts
A trigger fires as an execution. If the execution fails, a retry policy decides whether another attempt is scheduled. When no further attempt will be made, the execution is marked dead (isDeadLetter: true) and the trigger is done.
Which retry policy applies
Three sources, most specific first. The first one that says something wins; it replaces the others rather than merging with them.
| Precedence | Source | Set with |
|---|---|---|
| 1 | Per-trigger policy | ttr-retry-*header headers on /schedule, or a retry object on /declare and /bulk items |
| 2 | Tag policies | /tag-policies (or the dashboard). When a trigger carries several tags with a retry strategy, the most defensive values are merged — fewest attempts, longest delay, most restrictive retryOn. |
| 3 | Project default | PUT /project/retry-policy (or the dashboard's Tag policies page). Applies to every trigger whose tags express no retry opinion: triggers with no tags, tags with no policy, and policies whose strategy is none. |
Untagged triggers used to be never retried. Before September 2026 a trigger without a tag policy had no retry policy at all, so a single transient network error — a DNS hiccup, a 10-second connect timeout — was final. Every project now has a default policy (below) that covers those triggers. Set the project default's strategy to none to restore the old behaviour.
The default policy
Every project starts with:
| Field | Value | Meaning |
|---|---|---|
retryStrategy | exponential | delays double after each failure |
retryMaxAttempts | 3 | the first attempt plus two retries |
retryInitialDelaySeconds | 30 | attempt 2 after 30 s, attempt 3 after a further 60 s |
retryMaxDelaySeconds | null | no cap |
retryJitter | false | |
retryOn | network_only | only when no response was received |
network_only is deliberate: it retries the failures where nothing reached your endpoint (DNS, connect timeout, TLS, connection refused) or where the outcome is unknown (connection reset, request timeout), and leaves a 4xx/5xx your endpoint actually produced alone. Widen it to 5xx_and_network or non_2xx if your endpoint is idempotent.
Read or change it with an API key or a dashboard session:
GET https://api.timetriggers.io/project/retry-policyttr-api-key: ttr_…PUT https://api.timetriggers.io/project/retry-policyttr-api-key: ttr_…Content-Type: application/json{ "retryOn": "5xx_and_network", "retryMaxAttempts": 5 }
PUT is a patch: fields you omit keep their value.
Per-trigger retry
Opt a single trigger in on /schedule with ttr-retry-*header headers. Sending any of them sets a per-trigger policy; the others take these defaults:
| Header | Default | Values |
|---|---|---|
ttr-retry-strategyheader | exponential | none, fixed, exponential |
ttr-retry-max-attemptsheader | 3 | integer ≥ 1, counts the first attempt |
ttr-retry-initial-delayheader | 30 | seconds; first delay (every delay for fixed) |
ttr-retry-max-delayheader | none | seconds; cap for exponential |
ttr-retry-jitterheader | false | true / false; randomises each delay within ×0.5–1.5 |
ttr-retry-onheader | 5xx_and_network | network_only, 5xx_and_network, non_2xx |
POST https://api.timetriggers.io/schedulettr-api-key: ttr_…ttr-url: https://example.com/hookttr-retry-max-attempts: 5ttr-retry-on: network_only
ttr-retry-strategy: none pins the trigger to a single attempt whatever the tags or the project say. A per-trigger policy on a cron(...) trigger is stored on the generator and copied onto every instance. Re-posting a trigger without retry headers clears its per-trigger policy — /schedule always replaces the whole definition.
On /declare and /bulk, the same options live in a retry object on each item (an empty object {} opts in with the defaults):
{"upserts": [{"customKey": "invoice-42","url": "https://example.com/hook","retry": { "maxAttempts": 5, "retryOn": "network_only" }}]}
A changed retry object counts as an update (changedFields: ["retry"]); omitting it on a later call clears the policy.
Timeouts
Two independent budgets apply to every execution:
| Timeout | Default | Set by |
|---|---|---|
| Connect timeout — DNS + TCP + TLS handshake | 10 s | fixed |
| Request timeout — the whole exchange, from connect to the last byte of the response | 300 s (5 min) | requestTimeoutSeconds on a tag policy; the shortest across a trigger's tags wins |
A target that never completes the handshake fails after about 10 seconds with connect_timeout, not after five minutes. Both values are recorded on the execution (connectTimeoutSeconds, timeoutSeconds).
What an execution records when it fails
Alongside errorMessage, an execution that got no HTTP response carries:
errorKind— the classification:dns,connect_timeout,connection_refused,unreachable,tls,connection_reset,timeout,protocol,body_read,invalid_request,unknown.errorCode— the underlying code, e.g.ENOTFOUND,UND_ERR_CONNECT_TIMEOUT,ECONNRESET,CERT_HAS_EXPIRED.
The dashboard shows the kind as the result badge (CONNECT TIMEOUT, DNS, TLS, …) and the code and timeouts in the execution's details. errorMessage itself reads like Connect timeout after 10s: Connect Timeout Error (attempted address: 203.0.113.4:443, timeout: 10000ms) [UND_ERR_CONNECT_TIMEOUT] (POST https://example.com/hook).
For dns, connect_timeout, connection_refused, unreachable and tls nothing reached your endpoint; for connection_reset and timeout the request may have.
Finding and replaying dead executions
Every read endpoint accepts your API key (ttr-api-keyheader or Authorization: Bearer ttr_…), scoped to the key's project — no dashboard session needed. A reconciler can poll for terminal failures and replay them:
GET https://api.timetriggers.io/executions?deadLetterOnly=true&limit=100ttr-api-key: ttr_…
returns the executions with isDeadLetter: true, newest first, each with its jobId, errorKind, errorCode and the job's URL. To retry one:
POST https://api.timetriggers.io/jobs/{jobId}/executionsttr-api-key: ttr_…Content-Type: application/json{ "triggeredBy": "retry" }
which queues a fresh attempt with the trigger's stored method, headers and body. GET /jobs, GET /jobs/{id}/executions and GET /executions (with jobId, customKey, executionStatus, httpStatus, deadLetterOnly filters) work the same way — see the full spec.