Timeout-budget propagation¶
TL;DR. The remaining deadline travels with the request across
RestTemplate,RestClient,WebClient,OkHttp, Apache HttpClient 5, and Kafka. Two clocks: an RPC deadline (the caller is waiting) and event freshness (how old is this Kafka record). They are not the same, and Pulse does not treat them as the same.
The platform default timeout — 30 seconds, set once and forgotten — is what every downstream service uses. The original caller may have already given up after 2 seconds. The chain doesn't know, holds connections open, and feeds the retry storm that takes the cluster down.
Pulse propagates the deadline, not the timeout. Each hop reconstructs the
same absolute deadline (Pulse-Timeout-Deadline-Ms) and also stamps remaining
milliseconds for legacy hops. On HTTP, fail-fast and socket bounding are
opt-in. On Kafka, the consumer prefers the absolute header so a message
that sat in the topic does not get a fresh 2s budget. An explicit remaining of
0 is expired — it is not "no header", and Pulse will not mint the 2s default.
Two clocks¶
| Clock | What it answers | Default | How Pulse implements it |
|---|---|---|---|
| RPC / request deadline | The HTTP caller is still waiting. Should this hop even fire? | Observe only (stamp remaining + absolute deadline, still call) | Pulse-Timeout-Deadline-Ms (absolute epoch-millis) plus Pulse-Timeout-Ms (remaining) on HTTP and Kafka. Inbound prefers the absolute header. Opt-in: abort-on-exhaustion, apply-client-timeout. |
| Event freshness | Is this Kafka record too old to act on? | Off | pulse.kafka.skip-stale-records + skip-stale-max-age. Independent of the HTTP budget. |
A handler that returns 202 Accepted and publishes "charge the card later"
must not drop that event because the original request's 2s budget
expired. Pulse never skips a Kafka listener just because the RPC deadline
passed. Enable skip-stale only when your business rule is "ignore events
older than N".
Kafka record timestamps are not used to reconstruct the RPC deadline.
They are often event-time (order.placed_at), not produce wall-clock.
The producer stamps Pulse-Timeout-Deadline-Ms from the in-process
TimeoutBudget instead. HTTP outbound interceptors stamp the same header so
a slow reverse proxy cannot restart remaining-ms on the next service.
What you get¶
When the chain is healthy, the deadline shrinks at each hop:
Caller ──POST /orders Pulse-Timeout-Ms: 2000──▶ Edge
Edge ──GET /stock Pulse-Timeout-Ms: 1850──▶ Inventory (300ms elapsed)
Edge ──POST /charge Pulse-Timeout-Ms: 1500──▶ Payment (350ms elapsed)
When something is slow and the budget runs out, Pulse increments a counter and still makes the call by default. A single Prometheus query lights up:
This is the leading indicator of a cascading failure. With the shipped
PulseTimeoutBudgetExhausted alert, you see it minutes before the user does.
Turn it on¶
On by default with a 2 second budget per request. Outbound calls still execute when the budget is exhausted; see the opt-in flags below.
To set a different default, or to forward a different header name to match an existing convention:
pulse:
timeout-budget:
default-budget: 5s # used when no inbound header is present
inbound-header: X-Deadline-Ms # match your gateway's convention
To read the remaining budget from your own code:
TimeoutBudget.current().ifPresent(budget -> {
if (budget.remaining().toMillis() < 500) {
// skip the optional enrichment call — not enough time
}
});
What it adds¶
| Where | Key | Value |
|---|---|---|
| HTTP header (in / out) | Pulse-Timeout-Ms |
Milliseconds remaining on the deadline. 0 means expired, not "use the default". |
| HTTP / Kafka header | Pulse-Timeout-Deadline-Ms |
Absolute epoch-millis deadline (RPC clock). Preferred on inbound. |
| Kafka header (legacy + HTTP-shaped) | Pulse-Timeout-Ms |
Remaining ms at produce time. Consume falls back to "remaining from now" only when the deadline header is absent. |
| OTel baggage | pulse.timeout-budget.deadline.epoch.ms |
Absolute epoch-millis deadline |
| MDC (logs) | timeout_remaining_ms |
Snapshot at log time |
| Metric | pulse.timeout_budget.exhausted (tag: transport) |
Outbound calls made (or aborted) with remaining budget at or below minimum-budget |
The metric is tagged by transport: resttemplate, restclient, webclient,
okhttp, apache-hc5, kafka. So you can see which client gave up.
Fail fast (skip the doomed call)¶
When remaining budget is at or below minimum-budget, outbound HTTP
calls throw TimeoutBudgetExhaustedException instead of executing. Kafka
produces are never aborted — dropping a produce is not an observability
decision, and Kafka consume never skips because the HTTP deadline expired.
Enable abort only after you have confirmed the default 2s budget (or your own) matches the service's real latency envelope. Combined with abort, a 2s default will fail slow legitimate endpoints.
Bound the socket (the call cannot outlive the caller)¶
Stamping a header does not stop RestTemplate from waiting 30s. To actually cap the in-flight call:
| Client | What Pulse can do |
|---|---|
| OkHttp | Per-call connect / read / write timeout = remaining |
| WebClient | Reactor .timeout(remaining) on the exchange |
| Apache HttpClient 5 | Per-request responseTimeout = remaining |
| RestTemplate / RestClient | Not supported — no per-request timeout API. Use abort-on-exhaustion, or switch the factory to OkHttp / Apache. |
Do not turn this on in production while default-budget is the shipped 2s
unless 2s is your real p99 envelope. A healthy 800ms hop with 1.5s remaining
is fine; a 4s legitimate endpoint is not.
When to skip it¶
Disable when your platform already enforces a request budget end-to-end — Envoy timeouts, Istio request timeouts, gRPC deadlines you trust — and you don't want a parallel mechanism:
If you run an API gateway in front, configure it to set Pulse-Timeout-Ms
based on the gateway's own request timeout. Otherwise the first hop uses the
2-second default and only later hops see the propagated value.
Conditional gating¶
To skip the budget filter for some requests (synthetic probes, internal
admin traffic) without disabling the feature, use the shared
enabled-when block:
pulse:
timeout-budget:
enabled-when:
header-not-equals:
x-pulse-synthetic: "true"
path-excludes:
- /actuator
When the matcher rejects, no budget is established on baggage and
downstream calls see TimeoutBudget.current() == Optional.empty() —
your code already handles that.
Under the hood¶
Three pieces work together:
- A filter on the way in prefers
Pulse-Timeout-Deadline-Ms, else remaining-ms, else the default. An explicit0(or a past deadline) opens an expired budget — it does not fall back to 2s.minimum-budgetfloors only the implicit default, never an inbound header.safety-marginis subtracted when remaining-ms or the default establishes a new deadline; an absolute deadline is not taxed again. - An interceptor on the way out — wired into every supported HTTP and Kafka
client — stamps remaining-ms and
Pulse-Timeout-Deadline-Ms. - If the remaining budget is at or below
minimum-budgetbefore the call fires, Pulse incrementspulse.timeout_budget.exhaustedand stamps the remaining value (including0). The call still executes. Setpulse.timeout-budget.abort-on-exhaustion=trueto throwTimeoutBudgetExhaustedExceptioninstead — HTTP only; Kafka produces are never aborted. Setapply-client-timeout=trueso OkHttp / WebClient / Apache HttpClient 5 cannot wait longer than remaining. RestTemplate and RestClient still cannot set a per-request socket timeout.
The remaining-ms header name follows RFC 6648 (no X- prefix).
inbound-header and outbound-header can be configured separately if you
need to bridge between two conventions. The absolute deadline header name is
fixed.
Kafka event freshness (skip stale records) is documented with Kafka time-based lag.
Why this shape in 2026¶
There is still no IETF HTTP deadline header. gRPC sends a remaining timeout
and the server converts it to an absolute deadline on receipt — Pulse does
the same, then forwards the absolute value so a proxy cannot restart the
clock. OpenTelemetry baggage already carries the deadline in-process; the
headers are the hop that OTel does not own. In-process cardinality still
beats Collector views: the bill is incurred before the pipeline. Async
propagation stays on Micrometer ContextSnapshot (what Spring MVC / Boot 4
actually run); Java ScopedValue is not on that request path yet.
Source: TimeoutBudget.java ·
TimeoutBudgetFilter.java ·
TimeoutBudgetOutboundInterceptor.java ·
Runbook: Timeout-budget exhausted ·
Status: Stable since 1.0.0