Trail content
7 minEnglish narration, Portuguese subtitlesPodcast (RSS)
Chapters
  1. 0:00Opening
  2. 0:39The idea
  3. 1:18Before and after
  4. 2:01A real case
  5. 2:59A challenge
  6. 4:06Safe mechanics
  7. 5:27Risks
  8. 6:24Wrap-up
Transcript

Opening

The assistant schedules a Pix transfer. The bank takes a moment too long, and the call times out. So the code does what it always does. It tries again. Five times.

But the bank had already recorded the first one. The response just got lost on the way back. Five transfers go out, for one request.

In the last lesson, you saw this smell: blind retry. Now we'll fix it, step by step, with a number before and a number after. You'll leave knowing which errors deserve a second attempt, and how to make that second attempt harmless.

The idea

The idea fits in one sentence. Before you retry, classify the error.

A transient error can go away on its own. An unstable network, a rate limit, a service that's briefly down. Those get retries, with a growing wait and a cap.

A permanent error won't. Invalid input, no permission, not enough balance, a business rule. Those go back to whoever called, with an explanation they can act on.

And every call that changes the world carries an idempotency key. That's the piece that makes a retry safe.

Before and after

Before, there was one arrow. Any error went straight back to the call. After, the type of error picks the path.

The call goes out with a key. The service uses it to recognize a repeat.

The error is classified by its code or status. Never by searching the message text for the word timeout. Messages change. Codes don't.

Only transient errors loop back, with a growing wait and a cap.

Permanent errors become a clear answer. For the user, or for the model, so it can change course instead of insisting.

And success happens once, even when there were retries.

A real case

Let's walk through the dangerous case, the timeout that was actually a success.

A user asks to schedule two hundred and fifty reais for tomorrow.

The moment they ask, the system creates a request id. Not inside the retry loop. At the click.

The bank processes the transfer. Then the network drops the reply.

Your code sees a timeout. That's transient, so it retries. With the same key.

The bank recognizes the key, and instead of scheduling again, it returns the first result.

The trace shows two attempts, and one effect. That's the number you want to see.

Treating the timeout as transient was right. What stopped the duplicate was a key born with the user's intent, not with the attempt. Serious payment APIs accept that key for exactly this reason.

A challenge

Here's one for you. An email API can send back five errors. Too many requests. Invalid recipient. Service unavailable. Invalid API key. And duplicate key, message already sent. What do you do with each one?

Too many requests is transient. Wait as long as the service asks, then try again, within your cap.

Invalid recipient is permanent. Tell the user, or the model, that the address needs fixing.

Service unavailable is transient. Retry, with a growing wait between attempts. If it keeps failing past the cap, stop and fall back.

Invalid API key is permanent, and it's a configuration problem. No call will ever work, so don't retry. Alert the team.

And duplicate key is a success in disguise. The email already went out. Treat it as done, and don't send it again.

Safe mechanics

As with every refactoring here, start by measuring.

Pull from your logs the most common errors for each dependency, how many retries each one caused, and any duplicated effects. Two transfers for the same request is the number that matters.

Classify each service's error codes as transient or permanent. When in doubt, treat it as permanent. A wrong no is cheaper than a wrong yes.

Add an idempotency key to every call with a side effect, generated once per user intent.

Then retry only transient errors, with growing waits, a cap, and a little randomness in the wait. That randomness is called jitter, and it stops a thousand clients from retrying in the same second.

Write useful messages for permanent errors, for the user and for the model. Not enough balance on the account is something a person can act on. Error is not.

In the agent loop, add a repetition detector. The same tool, with the same arguments, after a permanent error, ends that path.

And measure again. Retries per error type, time to answer, and duplicated effects. That last one should be zero.

Risks

The first risk is the most common bug in this refactoring. The key is generated inside the retry loop. Every attempt gets a new key, the service sees new requests, and idempotency is gone. The key is born with the click, the ticket, the conversation, never with the attempt.

The second is stacked retries. The provider's SDK retries twice. Your function retries three times. The agent calls the tool again. That's nine attempts, and nobody agreed on that number. Turn off the SDK retry on calls you classify yourself. One place decides.

And some services don't accept a key at all. Then check the state before repeating. Is there already a transfer for this request? If you can't check, don't retry a side effect. Hand it to a person instead.

Wrap-up

So here's the idea to take with you. A good retry policy is mostly about saying no. No to errors that time won't fix, no to new keys on old intentions, and no to a third layer of retries. What's left is a second attempt that can never do harm.

In the next lesson, we'll see everything from this trail working together in a real system: invoice and contract extraction. See you there.

RefactoringSafety and reliability

Classify Errors Before Retry

Classificar Erro Antes de Tentar de Novo

Classify Errors Before Retry is an AI refactoring for LLM systems, in the Safety and reliability group. Separate transient errors from permanent ones and retry only what can succeed.

Element
29/35
Level
Intermediate
Read
4 min
Updated
On this page 9
  1. 01Problem
  2. 02Solution
  3. 03Before and after
  4. 04Why it works
  5. 05Safe mechanics
  6. 06Risks
  7. 07Challenge
  8. 08Related
  9. 09Check your understanding
  10. ··Quick answers

Problem

The assistant schedules a Pix. The bank API returns a timeout. Code retries five times with the same call. The bank had already recorded the first one. Five Pix transfers go out. Every error triggers another attempt, including errors that will never succeed, and side effects end up duplicated. This is the Blind Retry smell.

Solution

Before retrying, classify the error. Transient errors (network, rate limit, momentary unavailability) get retries with growing backoff and a limit. Permanent errors (validation, permission, balance, business rule) go back to the caller with an explanation. Every side-effect call carries an idempotency key.

Before and after

-for attempt in range(5):
-    try:
-        return bank.schedule_pix(key, amount, date)
-    except Exception:
-        time.sleep(2 ** attempt)
+TRANSIENT = {"TIMEOUT", "CONNECTION_RESET", "RATE_LIMITED", "SERVICE_UNAVAILABLE"}
+
+def schedule_pix(key, amount, date, request_id):
+    for attempt in range(3):
+        try:
+            return bank.schedule_pix(key, amount, date, idempotency_key=request_id)
+        except BankError as e:
+            if e.code not in TRANSIENT:
+                return {"ok": False, "error": e.code, "retryable": False,
+                        "message": HUMAN_MESSAGES.get(e.code, e.message)}
+            time.sleep(min(2 ** attempt + random.random(), 10))   # backoff with jitter
+    return {"ok": False, "error": "UNAVAILABLE", "retryable": True,
+            "message": "The bank is unstable. Try again in a few minutes."}

Before: any error, same response.

flowchart LR
  C["1. Call"]:::tool --> E{"2. Error?"}
  E -->|"any"| C

After: the error type decides.

flowchart LR
  C["1. Call with idempotency key"]:::tool --> E{"2. Error type"}
  E -->|"transient"| W["3. Wait and retry, with a limit"]:::tool
  W --> C
  E -->|"permanent"| R(["4. Explain to the caller"]):::io
  E -->|"ok"| OK(["5. Result"]):::io
  1. Every side-effect call carries a key the service uses to avoid repeating the effect.
  2. The error is classified by code or status, never by its message text.
  3. Only transient errors are retried, with growing backoff and a cap.
  4. Permanent errors become a clear answer the model or the user can act on.
  5. Success happens once, even with retries.

Why it works

Retrying only helps when the error’s cause can go away on its own. Unstable networks and rate limits pass; insufficient balance and an invalid Pix key do not. Classifying avoids spending attempts on what will not work, and avoids an attempt “succeeding” at the wrong moment.

The idempotency key solves the most dangerous case: a call that succeeded on the server, but whose response was lost on the network. Without the key, the retry repeats the effect. With it, the server recognizes the repeat and returns the first result. Serious payment APIs (Stripe’s, Brazilian banks’ Pix APIs) accept that key for this reason.

In an agent, a well-explained permanent error also helps the model: instead of repeating schedule_pix with the same arguments, it informs the user or changes course. Classify by code (INSUFFICIENT_FUNDS, 400, 429), never by if "timeout" in str(e). Messages change; codes do not.

Worked example: a timeout that was success

Input: schedule R$250 for tomorrow, request_id=chk_9f3a. The bank processes it, the network drops before the response. With no key, the retry creates a new chk and schedules again. With a key, the second POST returns the same schedule. Output on the trace: attempts=2, effect=1.

Treating the timeout as transient is right. What stops the duplicate is the key born with the user’s intent, not with the attempt.

Check +20 XP on first tryThe API returns 400 with INSUFFICIENT_FUNDS. Your loop retries with growing backoff, because ‘sometimes the balance changes’. What does that cause?

Safe mechanics

  1. Measure. Pull from the logs each dependency’s most common errors and how many retries each produced. Look for duplicated effects (two Pix transfers for the same request).
  2. Classify each service’s error codes as transient or permanent. When in doubt, treat as permanent.
  3. Add an idempotency key to every side-effect call, generated once per user intent.
  4. Implement retries only for transient errors, with growing backoff, jitter (a little randomness in the wait, so a thousand clients do not hit the same second), and a cap. Honor Retry-After when the service sends it.
  5. Write useful messages for permanent errors, for the model and the user.
  6. Detect repetition in the agent: the same tool with the same arguments after a permanent error ends that path.
  7. Measure again: retries per error, duplicated effects (should be zero), and time to answer.

Trap: three layers of retry

The provider SDK retries twice. Your function retries three times. The agent loop calls the tool again. Nine attempts, and nobody agreed on the cap. Turn off the SDK retry (max_retries=0) on calls you classify. One place decides.

Another trap: generating uuid4() inside the for attempt. Each turn is a new key, and idempotency vanishes. The key is born with the click, the ticket, the conversation’s request_id.

If the service does not accept a key, check state before repeating (“is there already a Pix with this request_id?”). With no check, do not retry a side effect.

Check +20 XP on first tryYou generate idempotency_key=str(uuid4()) at the start of each for attempt turn. The bank accepts the key. Why do Pix transfers still duplicate on a timeout?

Risks

  • Wrong classification. A permanent error treated as transient brings back the original problem. Review the list when the service changes.
  • An idempotency key generated per attempt. Then it protects nothing.
  • A service without idempotency support. In that case, check the state before repeating.
  • Stacked retries. The SDK retries, your function retries, and the agent retries. Pick a single place responsible for retries. Combine with Add Fallback Path when the transient error persists: after the cap, a breaker, not one more turn.

Challenge

Classify these errors from an email-sending API and say what to do with each one.

429 Too Many Requests
400 Invalid recipient address
503 Service Unavailable
401 Invalid API key
409 Duplicate idempotency key (message already sent)
Show solution
  • 429: transient. Wait (respecting Retry-After) and try again, with a limit.
  • 400 invalid address: permanent. Tell the model or the user the address needs fixing.
  • 503: transient. Retry with backoff; if it persists, open the breaker and use the fallback.
  • 401: permanent and a configuration problem. Do not retry; alert the team, because no call will work.
  • 409 duplicate key: a disguised success. The email was already sent; treat it as a positive result, without resending.

Check your understanding

Check +20 XP on first tryThe agent calls schedule_pix, gets INVALID_PIX_KEY, and calls again with the same arguments on the next step. Which piece of this refactoring is missing in the agent?

Check +20 XP on first tryThe provider returns 429 with a Retry-After: 20 header. Your backoff was 2s, 4s, 8s. What do you do on this attempt?

Check +20 XP on first tryA 409 Duplicate idempotency key arrives after a timeout. The email was already sent. How does the code treat it?

Quick answers

What is Classify Errors Before Retry?

Classify Errors Before Retry is an AI refactoring for LLM systems, in the Safety and reliability group. Separate transient errors from permanent ones and retry only what can succeed.

What problem does Classify Errors Before Retry solve?

It treats Blind Retry.

Which pattern does Classify Errors Before Retry lead to?

The Circuit Breaker and Idempotent Action design pattern.

Go further

Relations

Smells it treats 1
Part of the tracks 1
Search