Errors & Robustness

Exception Handling

Exception Handling

An agent talks to things it does not control — a model, an API, a database, a network. All of them fail sometimes. Exception handling is how your program survives that instead of stopping.

The basic shape

try:
    result = 10 / 0
except ZeroDivisionError as e:
    print("cannot divide by zero:", e)
finally:
    print("this always runs")
  • try — the code that might fail
  • except — what to do if it does
  • as e — the exception object, holding the message
  • finally — runs either way, for cleanup

Without the try, the program stops at the failing line and nothing after it runs.

Catch what you expect, not everything

try:
    data = json.loads(text)
except json.JSONDecodeError:
    data = {}

Naming the specific exception is the single most important habit on this page.

try:
    result = call_api()
except Exception:
    pass

That is the pattern to avoid. It hides typos, hides bugs in your own code, and turns a clear failure into an agent that quietly returns nothing. When someone says "it just stops working and there is no error", this is usually why.

You can catch several, in order, most specific first:

try:
    result = call_api()
except TimeoutError:
    result = retry()
except ConnectionError:
    result = None
except Exception:
    logger.exception("unexpected failure")
    raise

Note the last block: log it, then raise to let it carry on upwards. Catching in order to log and re-raise is legitimate; catching to swallow is not.

The exceptions you will meet

ExceptionCause
KeyErrordictionary key does not exist
IndexErrorlist position does not exist
TypeErrorwrong kind of value
ValueErrorright type, unusable content — int("abc")
AttributeErrorno such method or attribute
FileNotFoundErrorfile is not there
ZeroDivisionErrordivision by zero
TimeoutErrorsomething took too long
json.JSONDecodeErrortext was not valid JSON

Libraries add their own. Model SDKs typically raise RateLimitError, AuthenticationError and APIError; Pydantic raises ValidationError. Catch those by name once you know which library you are using.

else and finally

try:
    f = open("config.json", encoding="utf-8")
except FileNotFoundError:
    print("no config")
else:
    print("opened fine")
    f.close()
finally:
    print("done")
  • else runs only if nothing was raised
  • finally runs whatever happens, including when an exception is on its way out

For files and connections, with is better than finally — it closes for you:

with open("config.json", encoding="utf-8") as f:
    data = f.read()

Raising your own

def set_severity(value):
    if value not in {"P1", "P2", "P3"}:
        raise ValueError(f"unknown severity: {value}")
    return value

Fail early and loudly on bad input. A clear exception at the point of the mistake is far kinder than a confusing one three functions later.

Use the built-in types where they fit — ValueError for bad content, TypeError for the wrong kind of thing. Invent your own only when callers need to catch it specifically:

class ToolExecutionError(Exception):
    pass

raise ToolExecutionError("weather tool returned no data")

Retrying, which agent code always needs

External calls fail transiently. The standard answer is to retry with a growing wait, so you do not hammer a service that is already struggling.

import time

def call_with_retry(func, attempts=3):
    for i in range(attempts):
        try:
            return func()
        except (TimeoutError, ConnectionError) as e:
            if i == attempts - 1:
                raise
            wait = 2 ** i
            print(f"attempt {i + 1} failed ({e}), retrying in {wait}s")
            time.sleep(wait)

Three things make this correct rather than merely present:

  • it retries specific transient errors, not everything
  • it waits longer each time — 1s, 2s, 4s. This is called exponential backoff
  • it re-raises on the last attempt, so a genuine outage is not silently swallowed

Do not retry a ValueError or an authentication failure. Those will fail identically every time; retrying only wastes money.

For production, tenacity gives you this as a decorator with jitter and caps. Understand the loop above first.

Failing softly in an agent

Sometimes the right answer is not to crash but to tell the model what went wrong.

def run_tool(name, args):
    try:
        return TOOLS[name](**args)
    except KeyError:
        return f"Error: no tool called {name}"
    except TypeError as e:
        return f"Error: wrong arguments for {name}: {e}"
    except Exception as e:
        logger.exception("tool %s failed", name)
        return f"Error: {name} failed: {e}"

Returning the error as text lets the agent read it, correct itself and try again — which is often exactly what it will do. Note that it is still logged with a full traceback for you, while the model gets a short, useful sentence.

This is the one place where a broad except Exception is defensible, because a tool crash should not kill the whole run. It is defensible only because it logs.

Read the traceback bottom-up

Traceback (most recent call last):
  File "agent.py", line 12, in <module>
    print(students[10])
IndexError: list index out of range

The last line is the answer. The line above is where it happened. Everything else is the trail of how you got there.

Coming from Java or C#

ConceptJava / C#Python
Catchcatch (E e)except E as e:
Cleanupfinallyfinally
Auto-closetry-with-resources, usingwith
Throwthrow new E()raise E()
Base typeExceptionException
Checked exceptionsJava has themnone
Re-throwthrow;, throw e;bare raise
Ran-without-error branchnoneelse

The big difference is no checked exceptions. Nothing in a signature tells you what a function can raise, and nothing forces you to handle it. You find out from the documentation, or from production.

The practical consequence: be deliberate. Wrap the calls that genuinely reach outside your process, and let real bugs in your own code crash loudly so you find them.

Common mistakes

  • except Exception: pass, hiding real bugs.
  • Catching too broadly, too early, so a typo looks like an API failure.
  • Retrying non-transient errors such as a bad key.
  • Not re-raising on the final attempt, turning an outage into a silent empty result.
  • logger.error where logger.exception would have given you the traceback.
  • finally for closing files where with is cleaner.

Practise this

  • Trigger KeyError, ValueError and ZeroDivisionError on purpose and catch each by name.
  • Write a function that raises ValueError for bad input, and catch it at the call site.
  • Write the retry loop from memory, with backoff and a re-raise at the end.
  • Take an except Exception: pass — write one if you have to — and replace it with something specific that logs.

Try It Yourself

Copy this into a new Colab notebook and run it. Nothing to install.

example.py
1import asyncio
2from typing import TypeVar
3
4T = TypeVar('T')
5
6# Custom exceptions for AI applications
7class AIError(Exception):
8 """Base exception for AI-related errors."""
9 pass
10
11class RateLimitError(AIError):
12 """Raised when API rate limit is hit."""
13 def __init__(self, retry_after: int = 60):
14 self.retry_after = retry_after
15 super().__init__(f"Rate limited. Retry after {retry_after}s")
16
17class APIError(AIError):
18 """Raised when API returns an error."""
19 def __init__(self, status_code: int, message: str):
20 self.status_code = status_code
21 super().__init__(f"API Error {status_code}: {message}")
22
23# Retry decorator with exponential backoff
24async def with_retry(
25 func,
26 max_retries: int = 3,
27 base_delay: float = 1.0
28):
29 """Execute function with retry logic."""
30 last_exception = None
31
32 for attempt in range(max_retries):
33 try:
34 return await func()
35 except RateLimitError as e:
36 print(f" Rate limited, waiting {e.retry_after}s...")
37 await asyncio.sleep(e.retry_after)
38 last_exception = e
39 except APIError as e:
40 if e.status_code >= 500: # Retry server errors
41 delay = base_delay * (2 ** attempt)
42 print(f" Server error, retrying in {delay}s...")
43 await asyncio.sleep(delay)
44 last_exception = e
45 else:
46 raise # Don't retry client errors
47 except Exception as e:
48 last_exception = e
49 delay = base_delay * (2 ** attempt)
50 print(f" Error: {e}, retrying in {delay}s...")
51 await asyncio.sleep(delay)
52
53 raise last_exception or Exception("Max retries exceeded")
54
55# Simulate an unreliable API
56call_count = 0
57
58async def unreliable_api():
59 """Simulates an API that fails twice then succeeds."""
60 global call_count
61 call_count += 1
62
63 if call_count <= 2:
64 raise APIError(503, "Service temporarily unavailable")
65
66 return {"status": "success", "data": "Hello!"}
67
68# Demo the retry logic
69async def main():
70 global call_count
71 call_count = 0
72
73 print("Calling unreliable API with retry:")
74 try:
75 result = await with_retry(unreliable_api)
76 print(f"✓ Success: {result}")
77 except Exception as e:
78 print(f"✗ Failed: {e}")
79
80asyncio.run(main())