Node Flowdocs

Execution controls

Retries, backoff, jitter, retry budgets, every timeout class, concurrency caps, semaphores, rate limits, admission control, quotas and circuit breakers.

Everything that decides whether a task runs, when it runs again, and how many run at once. All of it is enforced server-side, at dequeue or in the decider — anything enforced in an SDK is advisory and the first worker written in another language bypasses it.

Where each control lives

ControlConfigured onApplied
Retries and backoffTask definition (retryCount overridable per task)Decider, on failure
Retry budgetTask definitionDecider, over a 5-minute window
scheduleToStart, startToClose, task timeout, heartbeatTask definitionTimer, armed when the task is scheduled
Workflow timeoutWorkflow definitionTimer, armed on the first evaluation
concurrentExecLimit, rate limit, semaphoresTask definitionDequeue — the queue hands out fewer tasks
maxConcurrentTasksWorkflow definitionDecider, when scheduling
maxConcurrentExecutionsWorkflow definitionStart — the request is refused
rateLimitConfigWorkflow definitionStart — the run is queued, not refused
Namespace quotasNamespaceStart / registration
Circuit breakersNODE_FLOW_CIRCUIT_BREAKERSystem tasks that leave the process
over at cap at cap a holder finishes at cap any gate closed Start request Namespace quota 429 refused maxConcurrentExecutions LIMIT_EXCEEDED refused rateLimitConfig per key RUNNING, awaitingAdmission Run admitted Decider schedules a task maxConcurrentTasks not scheduled this pass Task queued concurrentExecLimit rate limit tokens named semaphores stays queued Leased to a worker
Every gate a task passes

Retries

The policy

Set on a task definition:

curl -X POST "$NF_URL/v1/ns/default/metadata/task-definitions" \
  -H "x-api-key: $NF_API_KEY" -H 'content-type: application/json' \
  -d '{
    "name": "charge",
    "retryCount": 5,
    "retryLogic": "EXPONENTIAL_BACKOFF",
    "retryDelaySeconds": 2,
    "backoffScaleFactor": 2,
    "maxRetryDelaySeconds": 300,
    "jitter": 0.2,
    "retryBudget": 0.3,
    "nonRetryableErrors": ["card_declined", "INVALID_ARGUMENT"]
  }'
FieldTypeDefaultMeaning
retryCountint 0–1003Attempts after the first. 0 means run once.
retryLogicenumEXPONENTIAL_BACKOFFFIXED, LINEAR_BACKOFF, EXPONENTIAL_BACKOFF.
retryDelaySecondsnumber ≥ 01The base delay.
backoffScaleFactornumber ≥ 12Growth factor for the two backoff shapes.
maxRetryDelaySecondsnumber ≥ 03600Hard ceiling on any computed delay.
jitternumber 0–10.2Proportional randomisation, ±20% by default.
retryBudgetnumber 0–10.3Maximum share of recent executions that may be retries. 1 disables.
nonRetryableErrorsstring[][]Substrings of a failure reason that must never be retried.

Only retryCount can be overridden on a task inside a workflow. Writing retryLogic or retryDelaySeconds there is silently stripped by the schema — the workflow task schema does not have those fields. Policy belongs on the task definition.

The delay formula

FIXED                delay = retryDelaySeconds
LINEAR_BACKOFF       delay = retryDelaySeconds * backoffScaleFactor * attempt
EXPONENTIAL_BACKOFF  delay = retryDelaySeconds * backoffScaleFactor ^ (attempt - 1)

delay = min(delay, maxRetryDelaySeconds)
if jitter > 0:  delay = delay ± (delay * jitter)
delay = clamp(delay, 0, maxRetryDelaySeconds)

attempt is 1-based: 1 is the delay before the first retry.

The cap is applied twice — before and after jitter. Capping only beforehand lets upward jitter push the delay back over the ceiling, which defeats the point of having one.

With the defaults (retryDelaySeconds: 1, factor 2, cap 3600):

RetryRawAfter capWith ±20% jitter
11 s1 s0.8–1.2 s
22 s2 s1.6–2.4 s
34 s4 s3.2–4.8 s
8128 s128 s102–154 s
122048 s2048 s1638–2458 s
148192 s3600 s2880–3600 s

maxRetryDelaySeconds exists because Conductor's absence of it causes real production failures: attempt 12 of an exponential policy schedules a retry days into the future and the task looks silently lost.

Jitter

On by default, because synchronised retries from many workers are a self-inflicted thundering herd. jitter: 0.2 randomises the delay by ±20%. Set it to 0 only if you genuinely need deterministic timing and have one client.

Retry budget

The control that stops a degraded dependency being held down by the retry traffic its own degradation caused — the classic way a partial outage becomes a total one.

no yes no yes Every 5 minutes of TaskExecutionsfor this task definition total >= 20? budget never trips:a tiny sample lies retries / total > retryBudget? retries allowed budget spent:further retries fail fast
How the budget is evaluated
  • The window is 300 seconds, so a service that recovers is not punished for a burst an hour ago.
  • Fewer than 20 executions in the window never trips it, because a tiny sample would trip on the first retry before there is enough traffic to mean anything.
  • retryBudget: 1 disables it entirely.

When the budget is spent, a failure is not retried even though attempts remain. The task fails, and the workflow fails with it.

nonRetryableErrors

Each entry is checked as a substring of the task's reasonForIncompletion. So "card_declined" matches Stripe error: card_declined (do_not_honor).

What is retryable at all

Task statusRetried?
FAILEDYes, if attempts remain and the budget holds.
TIMED_OUTOnly under timeoutPolicy: RETRY — see below.
FAILED_WITH_TERMINAL_ERRORNever, whatever retryCount says.

A worker sends FAILED_WITH_TERMINAL_ERROR to say "this input will never succeed". The system tasks classify their own failures — a 4xx from HTTP is terminal, a 503 is not; INVALID_ARGUMENT from gRPC is terminal, UNAVAILABLE is not; an INLINE script that throws is terminal, because it will throw again on identical input.

optional

Orthogonal to retries. A task marked "optional": true still exhausts its retries; once it has, the failure is absorbed, the task ends COMPLETED_WITH_ERRORS, and the workflow carries on.

{ "name": "log_to_analytics", "taskReferenceName": "analytics", "type": "SIMPLE", "optional": true, "retryCount": 1 }

Timeouts

Five classes, each answering a different question. That is why they are separate numbers and not one.

scheduleToStartTimeout startToCloseTimeout timeoutSeconds: the whole budget heartbeatTimeout: gap between beats scheduled leased terminal
What each deadline measures
FieldMeasuresThe failure it names
scheduleToStartTimeoutEnqueued until leasedNobody is running this queue. A starved queue, not a slow worker — the difference between paging the platform team and paging the service owner. Conductor cannot express this.
startToCloseTimeoutLeased until terminalA slow or wedged worker.
timeoutSecondsScheduled through to terminalThe total budget, end to end.
heartbeatTimeoutGap between heartbeatsA worker that died mid-task, reclaimed promptly rather than at the end of a long lease.
responseTimeoutSecondsLease durationConductor-compatible alias. Default 3600.
pollTimeoutSecondsLong-poll holdHow long the server may hold a worker's poll open. Default 30.

All default to 0, which means unbounded, except the last two. A policy of 0 arms no timer at all.

curl -X POST "$NF_URL/v1/ns/default/metadata/task-definitions" \
  -H "x-api-key: $NF_API_KEY" -H 'content-type: application/json' \
  -d '{
    "name": "charge",
    "scheduleToStartTimeout": 120,
    "startToCloseTimeout": 60,
    "timeoutSeconds": 300,
    "heartbeatTimeout": 30,
    "timeoutPolicy": "TIME_OUT_WF"
  }'

Waiting tasks get only the total budget

WAIT, WAIT_FOR_WEBHOOK, HUMAN and PULL_WORKFLOW_MESSAGES are never dispatched, so the two deadlines that measure dispatch and execution do not apply to them — arming scheduleToStart on a WAIT would fire while it is legitimately waiting and mark it TIMED_OUT, turning the feature into a bug that looks like one of the engine's own guarantees.

Only timeoutSeconds is armed. For WAIT_FOR_WEBHOOK it is the only thing that ends a callback which never arrives.

timeoutPolicy

What a missed deadline means. Set on the task definition (and, at workflow level, on the definition).

PolicyEffect
TIME_OUT_WF (default)The workflow ends TIMED_OUT. The deadline was the workflow's deadline.
RETRYThe timeout counts as a failed attempt and the task retries if attempts remain.
ALERT_ONLYAn event is emitted and the task keeps its result; the workflow proceeds. A monitoring signal, not an outage.

Under TIME_OUT_WF, a timeout does not buy the task another full timeout. Retrying under it let a task with a 5-minute budget and three retries hold its workflow for twenty minutes while every attempt reported the same timeout. Only RETRY spends retries on a timeout.

Workflow timeout

{ "timeoutSeconds": 3600 }

Armed on the one evaluation guaranteed to happen exactly once per execution — the first. When it fires the run ends TIMED_OUT and its failure workflow is started, the same as any other failure.

timeoutPolicy on a workflow definition is accepted and stored but not consulted: a whole-run timeout always ends the run. The policy that matters is the one on the task definition, which decides what a task's missed deadline means.


Concurrency

concurrentExecLimit — per task definition

A global cap on in-flight tasks of this type, across every workflow and every run.

{ "name": "charge", "concurrentExecLimit": 2 }

Enforced at dequeue, under a transaction-scoped advisory lock keyed on the queue. A worker asking for ten when the cap is two gets two — and a second worker asking while those two are held gets zero. When a holder finishes, the next lease flows.

Counting in-flight work and then leasing against that count is a read-then-write with nothing between: ten dispatchers all read zero, all grant themselves the full cap, and the limit is exceeded by an order of magnitude. Every sequential test still passes, which is what makes it dangerous. The advisory lock is what closes it, and it contends only with other dispatchers of the same queue.

maxConcurrentTasks — per execution

{ "name": "fan_out", "maxConcurrentTasks": 2, "tasks": [ /* a fork over 8 branches */ ] }

Bounds fan-out blast radius within one run. A dynamic fork over ten thousand items would otherwise schedule all of them at once and bury the queue.

Operators and waiting tasks are exempt: they perform no external work, and blocking them would stall the control flow that decides what runs next — the cap would prevent the workflow making progress at all.

maxConcurrentExecutions — per definition

{ "name": "nightly_reconcile", "maxConcurrentExecutions": 1 }

Checked before the row is created, and a start over the cap is refused with LIMIT_EXCEEDED. Admitting a workflow and then stalling every one of its tasks against a concurrency cap looks identical to a broken worker pool from the outside; rejecting the start says exactly what happened.


Named semaphores

A shared ceiling across unrelated task types — the thing a per-definition cap cannot express: "these six different tasks must not exceed four concurrent hits on one fragile legacy API".

export_report semaphore: legacy_erppermits 4 sync_inventory reconcile_ledger Fragile ERP
One semaphore, several task types

Create it

curl -X PUT "$NF_URL/v1/ns/default/semaphores/legacy_erp" \
  -H "x-api-key: $NF_API_KEY" -H 'content-type: application/json' \
  -d '{"permits":4}'

Declare it on every task definition that must hold it

curl -X POST "$NF_URL/v1/ns/default/metadata/task-definitions" \
  -H "x-api-key: $NF_API_KEY" -H 'content-type: application/json' \
  -d '{"name":"export_report","semaphores":["legacy_erp"]}'

Watch it

curl -s -H "x-api-key: $NF_API_KEY" "$NF_URL/v1/ns/default/semaphores"
# [{ "name": "legacy_erp", "permits": 4, "held": 4 }]

The held count is the reason this endpoint exists rather than a plain listing: a semaphore quietly at its limit looks identical to one nobody uses, and "why is my task not running?" has to be answerable without reading the database.

Two properties worth knowing:

  • All or nothing. A task acquires every semaphore it needs or none of them. Taking a subset and waiting for the rest is how two tasks needing the same two permits deadlock, each holding one.
  • Permits are leased, not held. A worker that crashes releases its permits on expiry instead of blocking the semaphore forever; a poller sweeps stale holders.

An unconfigured semaphore does not gate anything — declaring semaphores: ["not_yet_created"] is a no-op until permits are set.


Rate limits

Two different things share the phrase. They solve different problems.

Task rate limit — throughput

{ "name": "partner_api", "rateLimitPerFrequency": 100, "rateLimitFrequencySeconds": 60 }

At most 100 dispatches per 60-second window, whatever the concurrency. This is what most third-party APIs actually meter, and it is not a concurrency cap: it bounds throughput rather than parallelism, and completing a task does not refill the budget.

The window is a fixed bucketfloor(now / size) — rather than a sliding one. Fixed windows admit a burst at a boundary, but the alternative needs per-event timestamps, and a burst of at most 2× for one window is a fair trade for a single upsert on the dispatch path.

Tokens are consumed when they are granted, so a worker must actually attempt to dispatch what it is given. Unused grants are lost for the window. That is the conservative direction: under-dispatching briefly is recoverable, exceeding a downstream limit may not be.

Workflow rate limit — per-key admission

{
  "name": "tenant_sync",
  "rateLimitConfig": { "rateLimitKey": "${workflow.input.tenantId}", "concurrentExecLimit": 1 }
}

At most concurrentExecLimit executions sharing the resolved key run at once. Later starts are queued, not refused: the run is created RUNNING with awaitingAdmission: true, schedules nothing, and is admitted when a holder finishes.

curl -s -H "x-api-key: $NF_API_KEY" "$NF_URL/v1/ns/default/executions/$ID" | jq '.awaitingAdmission'

Different keys never contend: three runs for acme queue behind each other while a run for globex starts immediately.

A key that resolves to nothing shares a bucket named by the expression itself, rather than escaping the limit — an unkeyed run is still a run against whatever the limit protects.

Combining them

When several gates apply, the most restrictive wins: the allowance is the minimum across concurrentExecLimit, the rate-limit tokens and the semaphores.


Namespace quotas

Platform-level limits, set per namespace by an administrator.

curl -X PUT "$NF_URL/v1/ns/default/quotas" \
  -H "x-api-key: $NF_API_KEY" -H 'content-type: application/json' \
  -d '{
    "maxConcurrentExecutions": 5000,
    "maxExecutionsPerMinute": 2000,
    "maxWorkflowDefinitions": 500,
    "maxSchedules": 100
  }'
QuotaChecked when
maxConcurrentExecutionsStarting a run
maxExecutionsPerMinuteStarting a run
maxWorkflowDefinitionsRegistering a definition
maxSchedulesCreating a schedule

Exceeding one is a 429 carrying { quota, limit, current, retryAfterSeconds }. Execution quotas are checked in the same transaction as the insert, so two concurrent starts cannot both see room for one more.

An absent quota is unlimited.


Circuit breakers

Shared by every system task that leaves the process — HTTP, HTTP_POLL, WEBHOOK, GRPC. Off by default, because a breaker changes how failures behave and that should be a decision, not a surprise after an upgrade.

NODE_FLOW_CIRCUIT_BREAKER='{"enabled":true}'
# or, with everything spelled out:
NODE_FLOW_CIRCUIT_BREAKER='{"enabled":true,"failureRatio":0.5,"minimumRequests":10,"windowMs":30000,"openMs":5000,"maxOpenMs":60000}'
OptionDefaultMeaning
enabledfalse
windowMs30000Outcomes older than this are forgotten.
minimumRequests10Below this many outcomes in the window, never open — small samples lie.
failureRatio0.5Failure fraction at or above which it opens. Must be > 0 and <= 1.
openMs5000How long it stays open before admitting a probe.
maxOpenMs60000The ceiling when repeated probes keep failing and the wait doubles.
failure ratio exceeded(with enough samples) openMs elapsed the single probe succeeds the probe fails(openMs doubles, up to maxOpenMs) closed open half_open
Breaker states

The failure this exists for: a dependency goes down, every task calling it takes its full timeout, and those tasks occupy the system-task runner for thirty seconds each. One dead host then consumes the capacity every other workflow needs.

Four decisions worth knowing:

  • Per process, not cluster-wide. A shared breaker would need shared state — a second dependency — to protect against a dependency being down, and would make the breaker itself a thing that can fail. Envoy and Hystrix are per-instance for the same reason.
  • Only server-side failures count. A 404 or a 422 is the caller's problem and says nothing about the dependency's health; counting it would let one workflow with a bad URL open the breaker for every other workflow calling the same host. Timeouts, connection errors, 5xx and 429 count.
  • An open breaker fails the task, but never terminally. The retry policy still owns what happens next. A terminal failure here would turn a transient outage into permanently dead workflows, which is precisely what the breaker is supposed to prevent.
  • Half-open admits exactly one probe. Letting the whole backlog through the moment the window expires is how a recovering service is knocked over again.

Breakers are keyed by target: HTTP and HTTP_POLL calling the same host share one, so a host known to be down is known to both. gRPC is keyed by the configured service name rather than its address, because that is the name an operator knows and two services on one host should fail apart.


Diagnosing "why is my task not running?"

Every control reports itself. LimitExceededError carries a control field naming which one tripped, because that question has to be answerable from the execution view rather than from server logs.

Is the run even admitted?

curl -s -H "x-api-key: $NF_API_KEY" "$NF_URL/v1/ns/default/executions/$ID" \
  | jq '{status, awaitingAdmission, reasonForIncompletion}'

awaitingAdmission: true means a per-key workflow rate limit is holding it.

Is work actually queued?

curl -s -H "x-api-key: $NF_API_KEY" "$NF_URL/v1/ns/default/queues/charge/depth"
curl -s -H "x-api-key: $NF_API_KEY" "$NF_URL/v1/ns/default/queues"

Is anyone polling?

curl -s -H "x-api-key: $NF_API_KEY" "$NF_URL/v1/ns/default/queues/workers"

Depth without workers is a fleet problem — very often a domain mismatch.

Is a gate closed?

curl -s -H "x-api-key: $NF_API_KEY" "$NF_URL/v1/ns/default/semaphores"
curl -s -H "x-api-key: $NF_API_KEY" "$NF_URL/v1/ns/default/metadata/task-definitions/charge"

Check concurrentExecLimit, rateLimitPerFrequency and semaphores on the definition, and held versus permits on each semaphore.

Force an evaluation

An escape hatch and a diagnostic — if this unsticks a run, something failed to enqueue an evaluation:

curl -X POST -H "x-api-key: $NF_API_KEY" "$NF_URL/v1/ns/default/executions/$ID/decide"

Metrics that move first

MetricWatch for
node_flow_decide_queue_oldest_secondsDecider lag. Depth alone cannot tell a drained burst from a stall.
node_flow_tasks_readyWork waiting for workers.
node_flow_tasks_delayedTasks waiting out a retry backoff — the signal during a retry storm, counted nowhere else.
node_flow_timers_overdueThe sweeper is behind; timeouts and waits will fire late.
node_flow_outbox_dead_letteredNever expected to be non-zero.

See Self-hosting → observability.


Choosing values

A rough starting point, to be argued with:

Task shaperetryCountretryLogicscheduleToStartstartToCloseOther
Idempotent internal call3EXPONENTIAL_BACKOFF, base 1 s12030
Third-party API, metered5EXPONENTIAL_BACKOFF, base 2 s, cap 30030060rateLimitPerFrequency to their quota
Payment or anything with money3EXPONENTIAL_BACKOFF, base 5 s60120nonRetryableErrors, and an idempotency key in the worker
Long batch job06000heartbeatTimeout: 60, heartbeat from the handler
Fragile legacy system2LINEAR_BACKOFF, base 10 s900300A named semaphore, concurrentExecLimit
Best-effort telemetry1FIXED, 1 s6010"optional": true on the workflow task

Two rules that are not negotiable:

  1. Set scheduleToStartTimeout on anything a worker runs. It is the only control that distinguishes "no workers on this queue" from "the worker is slow", and without it a starved queue hangs silently.
  2. Set a downstream idempotency key in any worker with side effects. Every task may be delivered more than once — see Workers → idempotency.

Next

On this page