All issues 25

Failover Weekly No. 23

Your request needs a deadline

Last week I argued that a dead-letter queue is a decision deferred, and that every mechanism which defers work owes you three things: a clock, an owner and a rate.

Your request needs a deadline: diagram

One budget. Every hop.

Last week I argued that a dead-letter queue is a decision deferred, and that every mechanism which defers work owes you three things: a clock, an owner and a rate.

I promised to go after the clock.

This week: why setting a timeout at every hop does not automatically give you an end-to-end deadline. And why the most useful thing a request can carry is how much time remains for a useful response.

Consider a retail checkout. This is a composite illustration of a failure pattern I have encountered, rather than an account of a particular customer incident.

A mobile app calls an Amazon API Gateway REST API. API Gateway invokes an AWS Lambda function. The function calls an internal pricing service over HTTP, then writes the order to Amazon DynamoDB.

The app waits eight seconds per attempt and retries twice. API Gateway’s integration timeout is configured to 29 seconds. Lambda’s function timeout is 30 seconds. The pricing client has no explicit overall request timeout. The DynamoDB client uses the AWS SDK for Java 2.x without explicitly configured API call or API call attempt timeouts.

Then pricing slows from 200 milliseconds to roughly 12 seconds.

Not down. Slow.

Ignore small network and write delays for a moment. The first attempt starts at second zero. At second eight, the app stops waiting and sends another. The first execution finishes around second 12, after its caller has abandoned that attempt. At second 16, the app sends a third attempt. The second execution finishes around second 20. At second 24, the app gives up on the third, which finishes around second 28.

One tap. Three executions. No response received within its attempt’s waiting window.

There need not be an API Gateway timeout at all. Each execution can finish inside 29 seconds and still finish too late for the app waiting on it. A client timeout also does not establish whether the order was written.

The missing piece is a shared definition of how long the overall operation is worth waiting for.

A timeout limits a wait. A deadline gives it context.

A timeout answers: how long am I willing to wait for this call?

A deadline answers: by when must this operation produce its useful result?

Those are related concepts. A timeout can implement a deadline if it is calculated from the time remaining. The problem is independently chosen timeouts that restart at every hop and every retry.

In the checkout example, eight seconds is a per-attempt limit. It is not an eight-second budget for the whole user interaction: the app spends roughly 24 seconds across three attempts.

If the product’s real requirement is an eight-second overall wait, every attempt and every backoff must fit inside those same eight seconds. Starting another attempt at second eight would already be too late.

The Amazon Builders’ Library describes propagating the remaining time through a call chain so downstream services can decide whether a response will still be useful. That is the idea to carry into your service templates. One operation. One budget. No fresh allowance just because the request crossed a boundary.

Defaults cannot know your user’s patience

The AWS SDK for Java 2.x does not set API call or API call attempt timeouts by default. That does not mean its HTTP clients have no network timeouts. These are separate layers, with settings that depend on the HTTP client and configuration.

Likewise, gRPC clients do not set a deadline by default. Set one deliberately and verify that your implementation propagates it to downstream calls.

These defaults are not a statement about how long your user should wait. That decision belongs in your application.

Propagate remaining time on the live request path

An absolute expiry timestamp can work, but comparing it across machines requires an explicit approach to clock synchronization and uncertainty.

For live RPCs, gRPC propagates a relative timeout after subtracting elapsed time. This avoids requiring synchronized wall clocks for that propagation. Its documentation describes automatic support in Java and Go, and explicit enablement in C++; you still need to preserve the relevant context in your code.

For an HTTP path you control, a remaining-time header can carry a similar contract. It is application metadata: a header does not enforce anything by itself.

At ingress, validate it and cap it to the service’s allowed budget. Define what missing or malformed values mean. A public caller must not be able to grant itself unlimited execution time.

Convert the accepted duration into a local deadline using a monotonic clock. Recalculate the remainder immediately before each outbound operation and after any retry backoff.

There is an important limit to this approach: a duration forwarded unchanged cannot account for time spent in transit or in an intermediary that does not participate. Keep the caller’s own deadline active, propagate cancellation where supported, and account for transport and response time. A safety margin helps; it is not a guarantee against arbitrary delays.

If you need a hard expiry across storage, long queues or process restarts, a bare remaining-time header is insufficient. You need durable expiry semantics, often an absolute timestamp with a defined clock-uncertainty policy.

The principle is to preserve the budget. The representation depends on where the work travels.

Retries become budget decisions

A retry needs enough time for the backoff, another attempt and the response path. It also needs a retryable failure, a safe operation and permission from your retry policy.

If a dependency’s p99 latency is 400 milliseconds and only 250 milliseconds remain, that is a reason to be cautious. It is not proof that the next call cannot finish. A percentile describes a distribution, not the duration of an individual request.

Use measured latency under relevant load to choose an admission policy. Keep attempts bounded, use backoff with jitter, and avoid multiplying retries across layers. AWS’s guidance makes the same connection between retries, additional load and idempotency.

A retry can be wasteful even after the original attempt has finished: the original may already have succeeded, the error may be permanent, or the user may no longer need the result.

The question is whether another attempt can still produce a useful, safe outcome inside the remaining budget.

Queues need a business expiry, too

This is where the distinction becomes more important.

The caller’s deadline says when to stop waiting for a response. It does not necessarily say when the business operation should stop existing.

A stale price preview may be safe to discard. An accepted order or a payment reconciliation task may still need to complete after the customer closes the app.

For queued work, define which meaning applies. Persist the business expiry when there is one, preserve it across retries and redrives, and include queue residence time when deciding whether work is still valid. Check again before consequential side effects where the business contract requires it.

Only discard expired work when its contract explicitly permits that outcome. Record the reason and count it. Work that must still complete needs an owner and a recovery path; it should not disappear because an HTTP caller stopped waiting.

The same rule applies to a dead-letter queue. Expiry can automate a decision you have already designed. It cannot design that decision for you.

Enforce at the boundaries you control

Managed service limits and application deadlines solve different problems.

API Gateway’s REST integration timeout is configuration, not a per-request interpretation of your custom remaining-time header. The documented range is 50 milliseconds to 29 seconds, with increases available for Regional and private REST APIs; those increases can require a lower Region-level throttle quota. Edge-optimized REST APIs do not support that increase. API Gateway REST API quotas.

Lambda’s context can expose the function’s remaining execution time. In Node.js, that is getRemainingTimeInMillis(). It measures the function’s ceiling, not the caller’s patience. Lambda context documentation.

Use the earlier of your application deadline and the platform’s remaining execution budget, then reserve time for the response and any required subsequent steps. Do not give pricing the entire remaining budget if you still need to write the order afterward.

For the Java SDK, set the overall API call timeout from that allocation and choose a per-attempt timeout within it. The overall timeout includes retries and time between them. Use request-level overrides when the allowance varies by request, and configure the underlying HTTP timeouts consistently. Java SDK timeout configuration.

AWS’s 9 September 2026 announcement is a useful illustration of the distinction: Lambda Managed Instances now support up to 90 minutes for asynchronous and event source mapping invocations. Synchronous invocations retain a 15-minute maximum. That additional capacity supports longer-running work; it does not define the useful lifetime of an interactive request. AWS announcement.

The implementation rule

The core calculation is small. Here is pseudocode for one participating service:

# At ingress: validate the caller's remaining time and cap it to this service's budget.
requested_ms = parse_ms(headers.get("x-remaining-ms"))  # None if missing or malformed
budget_ms = MAX_BUDGET_MS if requested_ms is None else min(requested_ms, MAX_BUDGET_MS)
deadline_ms = monotonic_ms() + budget_ms

def allowance_ms(reserve_ms):
    # Recalculate immediately before each outbound call and after any retry backoff.
    remaining = deadline_ms - monotonic_ms()
    platform = platform_remaining_ms()  # e.g. Lambda's remaining execution time; None if no limit
    if platform is not None:
        remaining = min(remaining, platform)
    return remaining - reserve_ms  # keep time for the response and any later steps

# Pricing must leave time for the order write that follows it.
pricing_ms = allowance_ms(RESPONSE_MS + ORDER_WRITE_MS)
if pricing_ms <= 0:
    raise DeadlineExceeded()
price = pricing.get_price(order, timeout_ms=pricing_ms, headers={"x-remaining-ms": str(pricing_ms)})

This is a sketch, not a drop-in client implementation. The client must actually enforce the timeout; your protocol must define propagation; your retry loop must recalculate after waiting. Where no platform execution limit exists, the application deadline remains the controlling bound.

For parallel calls, each branch shares the same latest completion time. Their elapsed durations do not add together as though they ran sequentially, but their load still does. A deadline is not a concurrency limit.

What a deadline does not do

A deadline does not stop every piece of work automatically. gRPC cancels the server call when its deadline expires, but application code is responsible for stopping work it spawned. Other runtimes may enforce their own execution limits. Neither behavior guarantees that an external side effect has been cancelled. gRPC server deadlines.

A deadline does not undo an order write. If the write commits at 7.9 seconds and the client times out at eight, the order exists. Use an idempotency key that stays the same across attempts of the same logical operation, enforce that contract on the server, and provide a way to recover the outcome. Making retries safe with idempotent APIs.

A deadline does not replace connection, read or other transport timeouts. Configure those alongside the overall operation budget, and verify what your particular client covers.

And a deadline does not make a slow dependency fast. It helps the system stop spending resources on responses that will arrive too late.

Measure the work that outlives its usefulness

Track deadline exhaustion separately from dependency failures. Record how much budget remains at each service boundary, how many retries you suppress, how much queued work expires under its business policy, and how much execution continues after cancellation.

Exercise the failure path: a dependency that slows without failing, backoff that consumes the remaining budget, a missing propagation header, and a write that succeeds just before the response is lost.

Those are the cases that tell you whether the deadline is a real contract or just another field in a trace.

Every resilience mechanism eventually asks some version of the same question: is this work still worth doing?

Make the response deadline explicit. Give durable business work its own completion and expiry rules. Carry both meanings across the boundaries where they matter.

Your timeouts protect individual waits. A shared deadline aligns those waits with the user’s experience.

Set the budget at the edge. Spend it deliberately. Never let a retry or a hop silently reset it.

What’s next

A deadline tells you when the waiting budget is exhausted. It does not tell you how to resolve work that has already changed the world.

Next week: cancellation and compensation. What happens to an in-flight write when the caller disappears, how to model recovery in an orchestrated workflow, and why “we’ll just roll it back” is a design that has to be built before it is needed.

Where does your request budget get reset today?

Get Failover Weekly by email

One essay every two weeks, plus a short note when a new article is published here.

All 25 issues