Prompt and context
An asynchronous request reads routing metadata before it knows the budget for downstream calls. The design must use the event loop’s monotonic clock, create a timeout with no initial deadline, reschedule an absolute deadline once metadata arrives, and keep caller cancellation distinct from a timeout.
Python documents asyncio.timeout() as a reschedulable asynchronous context manager and timeout_at() as an absolute deadline based on the event-loop clock. Cancellation caused by the context is transformed into TimeoutError outside the context.
What the interviewer is testing
Look for a clear distinction between relative duration and absolute deadline, business timeout and external cancellation, plus correct use of reschedule(), expired(), nesting, and wait_for(). Strong answers propagate one budget to every I/O and clean up resources.
Clarifying questions to ask first
- Which component owns the budget, and how much was spent reading metadata?
- Do the HTTP client, database, and queue accept a timeout or cancellation signal?
- Should child calls share one absolute deadline or have independent budgets?
- Is timeout a degraded response, a retry, or an error?
- What is the minimum Python version in production?
30-second answer framework
“I compute an absolute deadline with loop.time(). I start with asyncio.timeout(None), then call cm.reschedule(deadline) after metadata arrives. Cancellation created by the context becomes TimeoutError when it exits, so I catch that outside; an external CancelledError remains cancellation after cleanup. Every downstream operation receives the remaining budget, and nested calls cannot reset the full timeout. I use cm.expired() for diagnosis and test races.”
Step-by-step deep dive
Step 1: Express the deadline with a monotonic clock
Do not calculate remaining time from wall-clock time because clock corrections can jump. Use loop.time() and pass one absolute deadline to every downstream operation.
loop = asyncio.get_running_loop()
deadline = loop.time() + 2.0
async with asyncio.timeout_at(deadline):
await call_dependency(deadline)Step 2: Start a reschedulable context
When the budget is unknown, use asyncio.timeout(None) as cm. After metadata arrives, calculate the absolute deadline and call cm.reschedule(deadline). Keep one context so the preliminary work and downstream calls share one boundary.
Step 3: Propagate remaining budget
Each client computes deadline - loop.time() and treats a non-positive value as immediate failure. Passing the same deadline prevents routing, database, and HTTP layers from each granting a full relative timeout.
Step 4: Understand cancellation-to-timeout conversion
The context cancels the current Task internally and converts that cancellation to TimeoutError while exiting. Consequently, TimeoutError should be caught outside async with, not inside it.
try:
async with asyncio.timeout(1.0):
await slow_call()
except TimeoutError:
return degraded_result()Step 5: Preserve external cancellation
If a client disconnects or a parent cancels the request, CancelledError is not a business timeout. Release connections, locks, and temporary files, then re-raise it instead of returning a degraded success.
Step 6: Compare timeout, timeoutat, and waitfor
timeout(delay) uses a relative delay and can be rescheduled; timeoutat(when) takes an absolute monotonic deadline and is ideal for propagation. waitfor(aw, timeout) targets one awaitable, cancels it on timeout, and may wait for cancellation to finish. Composing many wait_for calls can overspend a request budget.
Step 7: Handle nesting and expiry
Timeout contexts can nest; an inner deadline should not exceed the outer one. After exit, cm.expired() tells whether the context actually reached its deadline. Ordinary exceptions, external cancellation, and successful results must remain distinguishable.
Step 8: Test races and cleanup
Test delayed metadata, an already expired deadline, inner-first timeout, simultaneous external cancellation and timeout, a downstream operation that ignores cancellation, reschedule(None), repeated rescheduling, and cleanup failure. Record deadline, remaining budget, cancellation reason, and downstream duration without sensitive payloads.
Model high-quality answer
“I create one absolute deadline with loop.time() and pass it downstream. When the budget is unknown, I enter timeout(None) and reschedule after metadata arrives. The context converts its internal cancellation to TimeoutError outside the block; external CancelledError keeps propagating. Nested calls use the earliest deadline, and clients and databases receive the remaining budget. Race tests verify no leaked Tasks or resources.”
Common mistakes
- Using
time.time()for a deadline → clock corrections change the budget → useloop.time(). - Catching TimeoutError inside the context → the transformed exception is not there → catch it outside.
- Treating external cancellation as timeout → client cancellation becomes a fake degraded result → keep the exception types distinct.
- Granting a full timeout at every layer → total latency exceeds the contract → propagate one absolute deadline.
- Ignoring wait_for cancellation wait → real duration exceeds the numeric timeout → measure cancellation convergence.
- Testing only success and one timeout → race paths leak resources → test reschedule, simultaneous cancellation, and cleanup failures.
Follow-up questions and strong responses
Follow-up 1: Why is timeout_at more stable than per-layer timeout?
Every layer targets the same absolute instant, so relative-time rounding and repeated budgets cannot accumulate beyond the request contract.
Follow-up 2: What if reschedule receives a past deadline?
The context expires on the next event-loop opportunity. Check remaining budget before rescheduling and avoid starting non-cancellable I/O when it is already exhausted.
Follow-up 3: How does a database obey the deadline?
Pass the remaining seconds to a statement timeout or cancellation API. Cancelling the Python Task alone does not necessarily stop a server-side query.
Follow-up 4: What do you record when cancellation and timeout race?
Keep the parent cancellation reason and cancellation count, and propagate external cancellation. Store expired() as a diagnostic field instead of labelling every exit a business timeout.
Follow-up 5: When would you choose wait_for?
Use it for one awaitable with a simple relative limit. Prefer timeout or timeout_at when calls share a dynamic deadline or a request-wide budget.