Deadlines
An operation without a time budget can outlive the caller that wanted it: the client gave up seconds ago, but the handler is still holding a connection, a bulkhead slot, and a transaction. Forze uses gRPC-style deadlines: a budget is bound once at operation entry, and everything downstream — hooks, the transaction, dispatched operations, resilience strategies, outbound calls — inherits it for free.
Declare the budget on the plan¶
A deadline is a property of the operation, not of the route or the caller. Declare it where the operation is composed:
registry = (
registry
.bind("orders.create")
.with_deadline(timedelta(seconds=5))
.finish()
.freeze()
)
Patch mode sets a default across many operations at once —
registry.patch(selector).with_deadline(timedelta(seconds=10)).finish() gives
every matched operation a budget in one line. Scope it to a namespace with
patch(selector, namespace=ns), or settle it into per-operation plans with
registry.materialize_patches() so it can't leak to a sibling registry on
merge — see Cross-cutting patches.
Merging is restrictive: across patches, explicit plans, and any caller-bound deadline, the tightest budget wins. A layer can shorten an operation's budget, never extend it — to give one operation more room than a broad patch default, narrow the patch selector instead.
Bind a caller budget at the boundary¶
A boundary (HTTP middleware, a worker loop) may additionally bind the caller's own budget:
from forze.application.execution import bind_deadline
with bind_deadline(timeout_s): # None is a no-op passthrough
result = await resolved_op(args)
Binding is task-scoped and tighten-only — a nested bind can shorten the
budget but never extend past the enclosing deadline. bind_deadline(None)
passes through, so an optional per-request timeout forwards without
branching. Ports that want to derive per-call budgets can read
remaining_time() — seconds left, or None when no deadline is bound.
What enforcement looks like¶
- Entry fail-fast — an operation invoked with its budget already spent fails immediately, before any hook runs.
- The whole plan is bounded — the budget covers hooks, the transaction, and dispatch chains, and propagates into dispatched operations.
- Resilience cooperates — the resilience executor gates its entire strategy chain from the outside, and a retry abandons a backoff sleep that would outlive the deadline, surfacing the real error instead.
Expiry raises exc.timeout (code="deadline_exceeded") — 504 at the
FastAPI edge, details suppressed. The kind is deliberately non-retryable:
within the same invocation the budget is spent, and a fresh invocation carries
a fresh deadline.
Deadline ≠ per-attempt timeout
The resilience TimeoutStrategy bounds a single attempt and keeps
raising retryable infrastructure, so a retry can take another shot. The
invocation deadline bounds the whole call and is final. They compose:
per-attempt timeouts inside, the deadline outside.
When no deadline is declared or bound, nothing changes — the unbound path is one ContextVar read, with no timeout machinery.
The budget is visible¶
A declared deadline projects through the operation catalog like every other
plan-derived fact: OperationCatalogEntry.deadline, an x-deadline-seconds
vendor extension plus a "Time budget" line on
generated FastAPI routes, and a
sentence in each MCP tool description — so clients
and agents can set their own timeouts instead of retrying a call that died of
budget exhaustion.
Budgets cross service boundaries¶
When one Forze service calls another, the outbound HTTP
adapter attaches the caller's remaining budget as an
X-Forze-Deadline-Budget header automatically (opt out per service with
HttpServiceConfig(propagate_deadline=False)). The receiving side honors it
only when asked:
app.add_middleware(
InvocationMetadataMiddleware,
ctx_dep=runtime.get_context,
bind_deadline_from_header=True,
)
The header carries a duration, not an instant, so clock skew between hosts doesn't matter; and because binding is tighten-only, a forged or stale value can only shorten the sender's own request — never extend it.
The budget is deliberately not a message-envelope header: deadlines belong to the synchronous call chain. A queued event consumed after a backlog must not inherit its producer's leftover budget.
Cancellation never skips committed work¶
A deadline firing — or a client disconnecting — cancels the operation's task. One window must survive that: after the root transaction commits, the deferred after-commit work (idempotency records, event dispatch) is a cancellation-protected critical section. It runs to completion even while cancellation is pending, and the cancellation re-raises afterwards — so a committed transaction is never left half-announced.