Errors and try/except
Catch specific errors, clean up with finally, and turn tool failures into dicts the agent loop can read.
An exception is an error that stops normal flow. If nobody catches it, the whole program dies. The last line of the message is usually the type and a short reason: ValueError: only https allowed. Read that line first. Then read the traceback from the bottom up to see which of your lines threw.
Tools fail all the time: bad URL, missing key, timeout. In an agent, that should rarely kill the process. You catch the error at the tool edge, save it as data, and let the loop choose: retry, skip, or stop.
If the tool fails, catch it and return a dict. The loop reads ok. It should not crash.
Catch, then keep goingA crash inside search that you never wrap becomes a crashed loop. The model never sees “search timed out.” You see a traceback in a terminal. Those are different products. One is an agent. The other is a broken script.
What you will learn
try,except,else,finallyraisewith a custom message- Tool failure as a dict vs an exception
- Never write a bare
except:
try / except / else / finally
try:
result = read_url(url)
except ValueError as e:
result = class="tok-s">"failed: " + str(e)
else:
print(class="tok-s">"no error")
finally:
print(class="tok-s">"tool finished")| Word | When it runs |
|---|---|
try | The risky work |
except ValueError | Only if that error type happened |
else | Only if try had no error |
finally | Always — success, failure, or return |
Catch a specific type. except ValueError is clear. except Exception is wide. Start specific. KeyError, TypeError, json.JSONDecodeError, ValueError are the usual agent set.
as e names the error object. str(e) is the message. type(e).__name__ is the type as text, useful in a dict you print.
else is optional. Use it when the success path should not run if you caught. Putting success code in try also works, but then a bug in the success path is caught by the same except. else avoids hiding those bugs.
finally is for cleanup: close, print “finished,” reset a flag. A return inside try still runs finally before the function actually returns. That is why the live box prints finished on every URL.
raise and a custom message
raise throws an error. You can attach a message (a string that explains what went wrong).
def read_url(url):
if not url.startswith(class="tok-s">"https://"):
raise ValueError(class="tok-s">"only https allowed")
return class="tok-s">"ok body"raise ValueError("only https allowed") is a built-in error type plus your message. The agent can print that message or store it. Write messages that help a person: what was wrong, what is allowed. "bad" is not a message.
Inside except, a bare raise throws the same error again. Use that after you log, if you still cannot handle it. That is for unexpected bugs in your loop. Tool failures should become dicts instead, so the loop keeps running.
You can also raise ValueError("...") from e to chain. Skip that until you need it. A single clear raise is enough.
Do not put secrets in the message. Raise ValueError("missing API_KEY"), not ValueError("bad key " + key). Traces copy messages.
Dict vs exception
Two ways a tool can fail:
| Style | Example | Effect |
|---|---|---|
| Exception | raise ValueError("timed out") | Must be caught, or the program stops |
| Dict | {"ok": False, "error": "timed out"} | The loop keeps running and reads ok |
At the tool edge, convert exceptions to data: a dict with ok and error. Inside the while loop, work with data. That is easier to test. Tests pass a dict. They do not have to catch.
Inside a tool, raise is still fine to stop bad input. call_tool catches and wraps. One wrapper, many tools.
You can list more than one type: except (ValueError, TypeError) as e:. Keep the tuple tight. Do not add Exception to that tuple “just in case.” Order matters if you stack except blocks: specific types first, wider later. except Exception then except ValueError will never hit ValueError, because Exception already caught it. Put ValueError first.
else on try is “no error.” It is not the same as else on if. If you skip else and put success code after the whole try/except/finally, finally has already run. The live box uses return inside except and else, and print in finally. Copy that shape for tools.
Walkthrough: one wrapper, many failures
import json
def call_tool(name, raw):
try:
args = json.loads(raw)
if name == class="tok-s">"read_url":
return {class="tok-s">"ok": True, class="tok-s">"body": class="tok-s">"ok body"}
raise ValueError(class="tok-s">"unknown tool")
except json.JSONDecodeError as e:
return {class="tok-s">"ok": False, class="tok-s">"error": class="tok-s">"bad json"}
except ValueError as e:
return {class="tok-s">"ok": False, class="tok-s">"error": str(e)}JSON parse errors belong in the same wrapper. JSONDecodeError is expected from models. Catch it. Do not catch NameError in the same bucket. NameError means you typo’d a variable. That should fail the test, not become an observation.
| Type | Usually means | Catch at tool edge? |
|---|---|---|
ValueError | bad input, policy reject | yes |
KeyError | missing required field | yes, or prevent with .get |
TypeError | wrong type or extra ** key | yes at executor |
json.JSONDecodeError | model text was not JSON | yes |
NameError | your typo | no — let tests fail |
KeyboardInterrupt | user stopped the program | never catch to swallow |
Never a bare except
except: with no type catches everything, including errors you should not hide: KeyboardInterrupt, SystemExit, memory errors, syntax bugs you introduced.
Never write a bare except:. Catch ValueError, KeyError, or another real type. Then log it. Swallowing every error is how agents “do nothing” and leave no trace. A silent except: pass is a defect, not a safety feature.
What goes wrong
- Bare
except:. - Catching too wide and hiding bugs.
- Forgetting
str(e), then storing the error object in JSON (dumpsmay fail). - Raising in the loop instead of returning a dict.
- Catching
Exceptionaround the whole agent, so nothing ever crashes even when your parser is wrong. - Wide
exceptabove a specific one, so the specific block is dead. - Putting success logic in
tryso a laterKeyErrorlooks like a tool failure.
Always store str(e) in the dict. An exception object is not JSON. json.dumps({"error": e}) can fail and hide the original error.
Run to execute this in your browser. Nothing is sent to a server.
Each call prints finished because finally always runs. Bad URLs return a dict, not a crash. Change the successful URL and watch else still return {"ok": True, ...}.
How agents use this
Tool failures are normal. The model should see “search timed out” as a row in the transcript, not as a crashed Python process. Catch at call_tool. Return {"ok": False, "error": ...}. Retry only when it makes sense. Let true bugs in your loop still show up. A custom message on raise is how you say what went wrong in one short line.
JSON parse errors belong in the same wrapper. JSONDecodeError is expected from models. Catch it. Do not catch NameError in the same bucket.
Print finished or append a trace row in finally if you need “this tool ended” even when it raised. Then the log has a close parenthesis. Agents that crash mid-tool with no row look like they hung. They did not hang. They died without logging.
Retry policy sits above the wrapper, in the loop, using the dict. if not obs["ok"] and "timeout" in obs["error"] and used < 3: then try again. The wrapper does not retry. If it did, a single user turn could hide a dozen failed calls. One catch, one dict, one loop decision.
Do not wrap the whole while in try/except Exception. A bug in your prompt builder would then become an observation forever, and you would ship it. Wrap tools. Let the loop crash in tests when you are wrong. That split is the whole lesson applied at agent scale.
Check your understanding