The single most useful thing to build is a classifier that runs at the exact point a tool call returns non-success. Every downstream decision (reject, retry, fail) depends on which bucket the failure falls into, and the buckets are not the same as HTTP status codes.
A practical four-way split, drawn from production tool-calling patterns in the n8n error-handling guide, looks like this:
| Failure class | Example | Who fixes it | Default action |
|---|---|---|---|
| Transport or network | dropped TCP, DNS timeout, 503 | Orchestration layer | Silent system retry with backoff |
| External service state | 429 rate limit, 500 upstream crash | Orchestration layer | Delayed system retry, honor Retry-After |
| Input validation | 400, malformed JSON, missing required field, unknown enum | Model | Reject the call and return the error |
| Logic or unexpected output | empty result set, unparseable body, business-rule rejection | Model, then a human if ambiguous | Retry only if the call is safe |
The key insight is that the first two classes never reach the model at all. A transport retry is a plumbing concern: the same request, resent, with no prompt change. If you wake the model up every time a 503 happens, you pay a full context replay for something the model cannot influence. The TokioCloud retry-budget analysis makes the same point from the cost side: model-level retries replay the conversation history, so their real cost is the cumulative context so far, not one step's tokens.
Here is a minimal classifier that maps an HTTP-ish failure to a decision. Keep it deterministic and outside the model:
def decide(failure, tool, attempts_used, elapsed_ms):
if failure.kind in ("transport", "rate_limit", "upstream_5xx"):
return "system_retry" # backoff + jitter, no prompt change
if failure.kind == "validation":
if attempts_used >= 2:
return "fail_task"
return "reject_call" # return error text to the model
if failure.kind == "ambiguous":
if tool.is_read_only_or_idempotent and elapsed_ms < 30_000:
return "retry_with_prompt"
return "fail_task"
Notice that "reject the call" and "retry with a corrected prompt" are different actions even though both send something back to the model. Rejection returns the tool's own error text and lets the model decide; a retry means your harness has already formed a hypothesis about what went wrong and is steering the correction. The first is cheaper and safer; the second should be rarer.



