Deadlines, not timeouts — deadline propagation with the grpc-timeout header

A timeout is relative and resets at every hop; a deadline is one absolute point in time that propagates, so each hop passes the next only the time remaining — which is what stops a downstream service from working for a caller who already gave up.

Previously

We saw one server keep computing 'Hello Ada' long after its wire died. Now stretch that across an A → B → C chain: without a shared clock, the deepest service keeps burning CPU for a request its caller abandoned a moment ago.

Scene 06

Deadlines, not timeouts

  1. Watch
  2. Try it
  3. Predict
  4. Capture
DEADLINE — one shared clock across A → B → Cdeadline propagation: ONBUDGET · 500ms left of 1sgrpc-timeout: 1sCONTEXT⏱ 1s○ livegreet("Ada")grpc-timeout: 700msCONTEXT⏱ 700ms○ livegreet("Ada")A · frontendgets 1s · work 80msCPUB · gatewaygets 920ms · work 300msCPUC · greetergets 700ms · work 200msCPUOne shared clock: each hop hands the next only the time that's left.grpc-timeout header = the remaining absolute time, re-expressed per hop.A call can still succeed on C yet read DEADLINE_EXCEEDED at A — both sides judge independently.
deadline = one absolute clock for the whole call →
grpc-timeout: remaining time handed to C ↓
What to watch for

A network call usually rides under a patience limit: 'give up if no answer comes back in time.' The naive way to express that is a TIMEOUT — a relative duration like 'fail after 1 second.' The catch is that a timeout RESETS at every hop: A waits 1s on B, but when B calls C, B starts C's clock fresh at another full second — so the chain as a whole can wait far longer than anyone intended. The fix is a DEADLINE: instead of a duration, you compute one absolute point in time ('give up at 12:00:01.000') and PROPAGATE it. Each hop subtracts the time already spent and hands the next hop only the remaining time. Watch greet("Ada") travel A → B → C under a 1000ms deadline: the budget bar up top shrinks as time is spent, and the grpc-timeout chip on each arrow shows the smaller 'time left' — one absolute clock, re-expressed per hop.

Implementation

Highlighted lines are the ones running in the diagram right now.

Client.call
each hop re-expresses the same absolute deadline as time left
def call(stub, req, ctx):
# remaining time on the ONE shared clock, not a fresh duration
remaining = ctx.deadline - now()
headers = {
'grpc-timeout': encode(remaining),
}
return stub.invoke(req, headers)
Server.handle
the receiver rebuilds an absolute deadline from the header
def handle(req, headers):
if 'grpc-timeout' in headers:
# honor the caller's clock — bind to the same moment
ctx.deadline = now() + decode(headers['grpc-timeout'])
else:
# no propagation: start a fresh full timeout, alone
ctx.deadline = now() + DEFAULT_TIMEOUT
result = doWork(req, ctx)
return result
Server.doWork
work races the deadline; losing it is DEADLINE_EXCEEDED or zombie work
def doWork(req, ctx):
while not done:
if now() >= ctx.deadline:
raise DEADLINE_EXCEEDED # stop, caller is gone
step() # one slice of the cWorkMs of work
return Reply(greeting=greet(req.name))

Where this sits in Build a gRPC-style RPC framework

Scene 06 of 14, in the Reliability act — Deadlines, cancellation, retries, and the interceptor onion.. A timeout is relative and resets each hop; a deadline is absolute and propagates the time remaining — so a downstream service never works for a caller that already gave up.

Up next. A deadline handles 'time ran out,' but a client often quits for a reason that has nothing to do with the clock — the user closed the tab — and the same downstream services need to hear 'never mind' the instant it happens.

All 14 scenes in Build a gRPC-style RPC framework · Every curriculum

Built with Arqly
Every scene in Build a gRPC-style RPC framework builds on the one before it.All 14 Build a gRPC-style RPC framework scenes