TAI BUI
Back to curriculum
CapstonePython

Function Call Dispatcher

The dispatcher is where the harness pays for every promise the schema made: timeouts, retries, dedupe, error mapping, all on one seam.

TIME: ~90 minutesPREREQUISITES: Phase 13 lessons 01-07, Phase 14 lesson 01

Learning Objectives

  • Wrap a tool handler in a per-call timeout that returns a typed error instead of hanging the loop.
  • Apply exponential backoff retry with jitter and a maximum attempt count.
  • Deduplicate retries on an idempotency key so a retry that races with a slow original does not run twice.
  • Map handler exceptions and transport faults onto a single error envelope the harness loop already understands.
  • Bound parallel dispatch with a concurrency limit so a fan-out of forty tool calls does not exhaust the event loop.

Where the dispatcher sits

The dispatcher is the layer that takes one tool call from the harness loop and turns it into either a result or a JSON-RPC-shaped error envelope. It sits between the harness loop (lesson twenty) and the tool registry (lesson twenty-one). The transport (lesson twenty-two) feeds the loop, the loop hands a tool call to the dispatcher, and the dispatcher looks up the handler in the registry and runs it.

Mermaid diagram

Timers, retries, and idempotency live here and nowhere else. The loop does not know about them, the registry does not, and the handler does not. That isolation is the point.

Check: Where the dispatcher sits0/2
1. What is the dispatcher in the harness?Define
2. Why do timers, retries and idempotency live only in the dispatcher?Explain

Timeouts

A timeout is a hard ceiling on how long one handler attempt may run. Each tool has a default, carried as timeout_ms on its registry record, and the harness can pass a per-call override that wins over the default. The dispatcher enforces it with asyncio.wait_for. When the timer fires, the handler task is cancelled and the dispatcher returns DispatchError(kind="timeout").

Whether a timeout retries depends on the tool. A db.write that timed out may or may not have committed, so retrying it can duplicate the write. The dispatcher reads the idempotent flag from the registry record: idempotent tools retry, non-idempotent tools do not.

Check: Timeouts0/6
1. What is a timeout in this dispatcher?Define
2. Why must the dispatcher enforce a per-call timeout that returns a typed error?Explain
3. A db.write tool is registered with idempotent=False and max_attempts=3. Its first attempt times out. What does dispatch return?Apply
4. A tool is registered with idempotent=True and max_attempts=3, and its handler hangs on every attempt. What does dispatch return?Apply
5. Rows are duplicated after a db.write times out and the dispatcher retries it. The tool should never have retried. Which registry field was set wrongly?Analyze
6. You add an email.send tool. Sending twice annoys users, and a timed-out send may have gone through. How should you register and call it?Design

Retries with exponential backoff

Exponential backoff with jitter means each retry waits longer than the last, with a random stretch added so that many callers do not retry in lockstep. The policy here is three attempts maximum.

text
attempt 1  -> delay 0
attempt 2  -> delay 0.1s * (1 + random[0..0.5])
attempt 3  -> delay 0.4s * (1 + random[0..0.5])

Only timeout and transient errors retry. A schema error, a not_found, or an internal error never does. Schema errors are deterministic, so a retry cannot change the outcome and only burns the budget.

The dispatcher also respects the harness budget, but it checks it before the retry loop starts. If the caller has zero tool calls remaining, dispatch() returns kind="budget_exceeded" with attempts=0 and no handler attempt runs.

Check: Retries with exponential backoff0/5
1. What is exponential backoff with jitter?Define
2. Which failures does this dispatcher retry?Explain
3. Why does adding random jitter to the backoff delay help?Explain
4. Attempt 3 of a retried call is about to start. Using the delay formula, how long can the wait before it be?Apply
5. The harness has zero tool calls remaining and calls dispatch. What comes back?Apply

Idempotency key dedupe

An idempotency key is a caller-supplied string that identifies one logical operation, so the dispatcher can tell a retry of that operation apart from a new call. It exists for a production bug that is easy to miss: a retry fires while the original is still in flight. The first call hangs at four point nine seconds, just under the timeout, and the retry fires at five seconds. Now two requests race against the same backend, and if the tool is payments.charge, you charged twice.

The dispatcher accepts an optional idempotency_key. If a call arrives while the same key is in flight, the dispatcher waits on the in-flight future and returns its result. Completed keys stay in the cache for sixty seconds to absorb late retries.

The key is the caller's job. The harness derives it from the planner: f"{step_id}:{tool_name}:{hash(args)}". The dispatcher does not invent keys, because a key derived from arguments alone makes two semantically different calls look the same.

Check: Idempotency key dedupe0/7
1. What is an idempotency key?Define
2. Why does the caller derive the idempotency key instead of the dispatcher?Explain
3. When a call arrives with a key already in flight, why does the dispatcher wait on the existing future instead of starting a second call?Explain
4. Call A with key s7:charge:9f2 is still running when call B arrives with the same key. How many handler invocations happen, and what does B return?Apply
5. Call A with a given key finished 30 seconds ago. A late retry with the same key arrives now. What does the dispatcher do?Apply
6. A payments.charge call hangs for 4.9 seconds against a 5 second timeout. The retry fires at 5 seconds, and the customer is charged twice. Which ingredient was missing?Analyze
7. A planner builds keys from the tool name and arguments only. Step 3 and step 9 call the same tool with the same arguments for different reasons, and step 9 returns step 3's cached result. What is wrong?Analyze

Error envelope

The error envelope is the single shape every failed dispatch returns, whatever went wrong underneath.

text
DispatchError
  kind        : "timeout" | "transient" | "schema" | "not_found" | "internal" | "budget_exceeded"
  message     : str
  attempts    : int
  jsonrpc_code: int   (one of -32601, -32602, -32603)

The harness loop maps kind to its next state. schema and not_found go to on_error and trigger a replan. timeout and transient also go to on_error, and whether they replan depends on how many attempts were used. budget_exceeded triggers on_budget_exceeded.

Check: Error envelope0/3
1. What is the error envelope?Define
2. Why map handler exceptions and transport faults onto one envelope?Explain
3. A call's arguments fail schema validation. What does the dispatcher do?Apply

Concurrency limit on fan-out

A concurrency limit caps how many dispatches run at the same moment. Without one, gather(*calls) starts every coroutine at once. Forty tool calls means forty open sockets or forty subprocess pipes, and most backends do not like forty parallel connections from one client.

The dispatcher wraps gather in a semaphore with a default limit of eight. Each call acquires the semaphore before dispatching and releases it on completion. The caller still gets gather-shaped output, but the scheduling underneath is bounded.

Check: Concurrency limit on fan-out0/5
1. What is a concurrency limit?Define
2. Why does the dispatcher put a semaphore around its gather of tool calls?Explain
3. A harness fans out forty tool calls with default dispatcher settings. At most how many handlers run at once?Apply
4. A backend refuses connections whenever the agent fans out forty calls. The calls use gather directly and are not routed through the bounded gather. Which change fixes it?Analyze
5. A harness fans out forty calls to a backend that handles only four connections per client. What should you change?Design

Flow for one call

Mermaid diagram

Author's Note

I'd read the idempotent flag on every registry record before trusting the timeout behavior in this lesson. A timeout retries only for idempotent tools, and a db.write that timed out may or may not have committed, so a wrongly set flag turns a timeout into a duplicate write.

My bet is that key derivation belongs in the harness, where the planner knows the step. A key built from arguments alone makes two semantically different calls look the same, so I'd keep step_id in it.

How to read the code

code/main.py defines Dispatcher, DispatchError, and TransientError. The dispatcher takes a registry on construction, and the async dispatch(name, args, ...) is its only entry point. Per-attempt timeouts are applied inline inside _run_with_retries using asyncio.wait_for. gather_bounded(calls) runs many dispatches under the concurrency limit.

code/tests/test_dispatcher.py covers timeout firing, retry on transient, no retry on schema error, idempotency dedupe (two concurrent calls with the same key collapse to one handler invocation), and concurrency limiting (the semaphore in action).

The tests use asyncio.sleep(0) and deterministic Counter-based handlers, so they finish in milliseconds and do not depend on wall-clock timing.

Going further

Production dispatchers usually add two things on top of this one. The first is structured logging at every transition. The loop's event stream already gives you some of that, but the dispatcher should also emit dispatch.attempt and dispatch.retry events. The second is a circuit breaker: after N failures in a window, a tool gets a cool-down period during which dispatches return immediately with a new kind="circuit_open" (not one of the six kinds above) instead of attempting the handler. Both fit on top of this dispatcher without changing the contract.

Lesson twenty-four glues the dispatcher to a plan-and-execute agent, so you see all four pieces in motion.

Warm-up0/3
1. You click Pay and the page hangs, so you click Pay again. What is the main risk?Warm-up
2. In a tool-calling agent, what does the model produce when it wants to use a tool?Warm-up
3. Hundreds of clients lose a connection at the same instant and each retries after exactly one second. What goes wrong?Warm-up
Check: Going further0/1
1. A flaky tool fails 90 percent of its calls for ten minutes, and each caller waits through all its retries every time. You must stop wasting attempts on it without changing the dispatcher's contract. Which design fits?Design

Code