Error handling, retries & logging

Fail loudly, retry sensibly, log what happened.

Intermediate22 min · lesson 7 of 14

Every time your automation reaches across the network, it is placing a phone call to someone who might not pick up. The reputation service you query for an IP address (Internet Protocol address, the number that identifies a machine on the network) could be down for maintenance. The connection could drop halfway through. The server could be swamped and answer four seconds late, or never answer at all. A script that assumes the call always connects, always replies, and always says something sensible is a script that runs fine on your laptop and falls over at 3 a.m. during the exact incident you wrote it for.

Making a script survive its bad days comes down to four habits, and this lesson builds each one. Catch the specific failures you expect, and let the ones you did not expect crash loudly instead of hiding. Retry a call that failed for a temporary reason, waiting a little longer before each attempt so you are not pounding on a server that is already struggling. Put a time limit on every call, so one slow server cannot freeze the whole run. And swap your scattered print() lines for real logging, so that when something breaks you are left with a timestamped record of what happened rather than a guess.

try, except, else, finally

Python's way of handling failure is the try statement, and it breaks into four parts that people mix up constantly. Cooking dinner is a fair picture of the shape. try is the attempt: you start the food on the stove, which is the step that might go wrong. except is your backup plan for one specific way it fails, like ordering takeout when the pan catches fire. else is what you do only when nothing burned: plate the meal and sit down. finally is the step you take every single time, whether dinner turns out a triumph or a disaster, which here is turning off the stove. In code, finally is where you close the file, release the lock, or note that the work is done. Here is a loader that reads an API (application programming interface) token off disk before the rest of a tool starts.

load_secret.py
import sys
def load_token(path):
try:
with open(path) as f:
token = f.read().strip()
except FileNotFoundError:
print(f"[load_token] no token file at {path}")
raise
else:
print(f"[load_token] loaded token ({len(token)} chars)")
return token
finally:
print("[load_token] cleanup done")
if __name__ == "__main__":
try:
token = load_token(sys.argv[1])
except FileNotFoundError:
print("startup aborted: no credentials")
sys.exit(1)
print(f"token starts with {token[:3]!r}")
~/secopslog — bash
$ python3 load_secret.py api.token
[load_token] loaded token (15 chars) [load_token] cleanup done token starts with 'sk-'
$ python3 load_secret.py missing.token echo $?
[load_token] no token file at missing.token [load_token] cleanup done startup aborted: no credentials 1

Two runs, two paths. When the file exists, the except block is skipped, the else block runs and returns the token, and finally prints its cleanup line on the way out. When the file is missing, except catches the FileNotFoundError, prints a note, and then hits a bare raise, which is the re-raise: it throws the same exception onward so the caller can decide what to do. Here the caller decides the tool cannot start without credentials and exits with a non-zero status, which is why echo $? shows 1. Notice the shape of it: the low-level code adds context about what failed, and the high-level code decides how to respond. Swallowing the error at the bottom would have let the tool run on with no token, which is worse than stopping.

Catch what you can name

There is a tempting shortcut you should treat as a trap: except: with nothing after it, the bare except. It is a fishing net with no holes, and it catches everything, including the fish you needed to let swim past. The right instinct is the opposite. Name the specific exception you know how to handle, and let everything else travel up the stack. This small script shows why the difference matters.

bare_vs_specific.py
def shutdown():
raise SystemExit("operator asked to stop")
# WRONG: a bare except eats even the signals meant to stop the program
try:
shutdown()
except:
print("bare except swallowed the shutdown; the script keeps running")
# RIGHT: name the exceptions you actually handle
try:
shutdown()
except ValueError:
print("handled a bad value")
~/secopslog — bash
$ python3 bare_vs_specific.py
bare except swallowed the shutdown; the script keeps running operator asked to stop

The first block calls shutdown(), which raises SystemExit, the exception Python uses to end a program cleanly. The bare except grabs it and prints its line, so the shutdown you asked for never happens and the script rolls on. The second block asks only about ValueError. SystemExit is not a ValueError, so it passes straight through, the program stops the way it was told to, and prints its message. The rule to carry: the widest catch you should ever write is except Exception, and only to log the problem before re-raising it. Reach for the exact exception name whenever you can.

A bare except can trap the Ctrl-C that would save you
except: with nothing after it catches BaseException, which includes KeyboardInterrupt (the exception raised by your Ctrl-C) and SystemExit (a clean shutdown request). Wrap a stuck network loop in a bare except and you can no longer interrupt it from the keyboard, and a requested shutdown gets eaten. It hides your own mistakes too: a typo in a variable name raises NameError, and the bare except quietly reports your bug as handled. Catch the specific exceptions you expect. If you genuinely need a catch-all to log-and-reraise, write except Exception, which leaves Ctrl-C and SystemExit alone to do their jobs.

Give your failures a name

When your own code detects a problem the built-in exceptions do not describe, define your own. A custom exception is a labeled failure. Instead of raising a generic ValueError that a caller cannot tell apart from a dozen unrelated ones, you raise FeedUnavailable, and any code calling you can catch that one thing precisely. Defining it takes a single line: a class that inherits from Exception.

custom_exc.py
class FeedUnavailable(Exception):
"""The threat feed gave no answer we can use."""
def get_score(ip, feed):
if ip not in feed:
raise FeedUnavailable(f"no data for {ip}")
return feed[ip]
try:
score = get_score("203.0.113.7", feed={})
except FeedUnavailable as err:
print(f"triage skipped: {err}")
~/secopslog — bash
$ python3 custom_exc.py
triage skipped: no data for 203.0.113.7

The caller catches your failure by name and moves on to the next indicator, instead of crashing on it or, worse, mistaking a missing lookup for a clean all-clear. A named exception is how a busy pipeline tells the difference between the feed being down and the feed saying an IP is safe.

Retry, back off, and set a timeout

A temporary failure deserves a second try. Redialing a busy phone line is the everyday version: you do not give up after one busy signal, but you also do not mash redial a hundred times a second. You wait, and you wait longer each time. That growing pause is exponential backoff, and in code it is one line: time.sleep(2 ** attempt), which waits 1 second, then 2, then 4, then 8. It matters for security, not only for manners. A tight retry loop hammering a failing service is a self-inflicted denial of service (DoS, flooding a system with more requests than it can handle), and if that service rate-limits or bans the API key your whole team shares, one runaway script blinds every tool that depends on it. Back off, and you cooperate with the server instead of attacking it.

Backoff handles a call that fails fast. The nastier failure is a call that does not fail at all: it goes quiet and hangs. A retry loop cannot rescue you from that, because a hung call never raises the exception your retry depends on. Every call needs a deadline. The requests library takes a timeout= argument; a subprocess you launch takes one too. Here is an external scanner that decides to run forever, kept on a two-second leash.

timeout_demo.py
import subprocess
# Run an external scanner, but never let it hang the pipeline.
try:
subprocess.run(
["sleep", "30"], # stand-in for a slow external scanner
timeout=2,
check=True,
)
except subprocess.TimeoutExpired:
print("scanner exceeded its 2s budget; killed it and moving on")
~/secopslog — bash
$ python3 timeout_demo.py
scanner exceeded its 2s budget; killed it and moving on

After two seconds, subprocess.run kills the child and raises TimeoutExpired, your handler catches it, and the run moves on instead of stalling until someone notices the dashboard went cold. If hand-writing retry loops wears thin, the tenacity library packages the whole pattern behind a decorator: how long to wait, how the wait grows, how many attempts, and which exceptions count as worth retrying. It gives the same behavior as the loop you are about to write, minus the bookkeeping, at the cost of one more dependency to vet.

retry_tenacity.py
from tenacity import (retry, stop_after_attempt,
wait_exponential, retry_if_exception_type)
@retry(
retry=retry_if_exception_type(TimeoutError),
wait=wait_exponential(multiplier=1, max=30),
stop=stop_after_attempt(4),
reraise=True, # raise the real error after the last try, not RetryError
)
def query_feed(ip):
... # the same call, without the hand-written for/sleep loop
One trip through the retry loop
query_feed(ip) inside try
one attempt, with a timeout so it cannot hang forever
no error
else: log INFO, return the data
the happy path ends the loop right here
TimeoutError
except: log WARNING, sleep 2**attempt
a temporary failure; wait longer, then loop and try again
attempts used up
raise FeedUnavailable
stop retrying and let the caller decide what to do
A retry loop only rescues you from temporary failures. Catch the one error worth retrying, wait longer before each attempt, and after a fixed number of tries give up loudly with a named exception the caller can handle.

Stop printing, start logging

print() is a sticky note. It tells you something right now and carries no date, no severity, and no way to turn it down when the noise gets loud. The logging module in Python's standard library is the ship's log instead: every line is stamped with the time and a level, and you can make the whole thing quieter or louder without touching a single message. The levels run from calm to alarmed: DEBUG (fine detail for chasing a bug), INFO (normal progress), WARNING (something looks off but the run continues), and ERROR (something failed). You set a threshold, and anything below it stays silent.

One detail earns its keep for automation. logging writes to standard error (stderr, the stream meant for diagnostics) by default, which leaves standard output (stdout, the stream meant for real results) clean for your actual data. That separation is what lets a teammate pipe your tool into another program and receive only the findings, while your log chatter goes to the terminal or a file. Set the format once with a timestamp, and every line documents itself.

levels.py
import logging
import sys
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-8s %(message)s",
datefmt="%H:%M:%S",
stream=sys.stderr,
)
log = logging.getLogger("triage")
log.debug("opening connection to feed") # below INFO: never printed
log.info("feed reachable")
log.warning("feed answered slowly")
log.error("feed returned no data")
~/secopslog — bash
$ python3 levels.py
09:41:07 INFO feed reachable 09:41:07 WARNING feed answered slowly 09:41:07 ERROR feed returned no data

The DEBUG line never printed, because the threshold was set to INFO. Lower it to logging.DEBUG when you need the detail, then put it back. The timestamp, the padded level name, and the message all come from that one format string, so every line across your whole tool lines up and sorts cleanly. That %(levelname)-8s pads the level to eight characters, which is why the columns stay straight whether the word is INFO or WARNING.

Your logs are a place secrets go to leak
Logging is a common way for credentials to escape. A line like log.debug('headers: %s', request.headers) writes your API key straight into the log file, where anyone with access to your log store or SIEM (security information and event management, the system that collects logs for search and alerting) can read it back. Never log tokens, passwords, full request headers, or raw request bodies. Log the fact that a call was authenticated, not the secret that authenticated it. And keep DEBUG off in production, because DEBUG is exactly where people dump full payloads while chasing a bug and then forget to take it out.

Putting it together: a call that retries and logs

Here is the whole shape in one function. It defines a named exception for the failure that matters, retries a flaky call with exponential backoff, logs every attempt so you can reconstruct what happened after the fact, and keeps the real answer on stdout. The query_feed stand-in times out twice and then answers on the third try, which is exactly the ragged pattern a real network hands you.

check_ip.py
import logging
import sys
import time
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
datefmt="%H:%M:%S",
stream=sys.stderr, # logs to stderr; data stays on stdout
)
log = logging.getLogger("iptriage")
class FeedUnavailable(Exception):
"""The threat feed gave no answer after every retry."""
def query_feed(ip):
"""Stand-in network call: times out the first two times, then answers."""
query_feed.calls += 1
if query_feed.calls < 3:
raise TimeoutError("read timed out")
return {"ip": ip, "score": 100}
query_feed.calls = 0
def check_ip(ip, attempts=4):
for attempt in range(attempts):
try:
data = query_feed(ip)
except TimeoutError as err:
wait = 2 ** attempt
log.warning("attempt %d/%d failed (%s); retrying in %ds",
attempt + 1, attempts, err, wait)
time.sleep(wait)
else:
log.info("attempt %d/%d ok: %s scored %d",
attempt + 1, attempts, ip, data["score"])
return data
raise FeedUnavailable(f"{ip}: no answer after {attempts} attempts")
if __name__ == "__main__":
result = check_ip("203.0.113.7")
print(result["score"]) # the only thing on stdout
~/secopslog — bash
$ python3 check_ip.py
09:41:07 WARNING iptriage: attempt 1/4 failed (read timed out); retrying in 1s 09:41:08 WARNING iptriage: attempt 2/4 failed (read timed out); retrying in 2s 09:41:10 INFO iptriage: attempt 3/4 ok: 203.0.113.7 scored 100 100

Read the timestamps: the first retry waits one second, the second waits two, so the third attempt lands three seconds after the first. Two WARNING lines record the failures, one INFO line records the success, and the only thing on standard output is the number 100. Prove that last part by throwing the logs away and keeping the data, which is what a downstream program in a pipe would see.

~/secopslog — bash
$ python3 check_ip.py 2>/dev/null
100

Send stderr to a log collector and let stdout flow into the next stage of your pipeline, and this one function behaves the same whether you run it by hand or a scheduler runs it at midnight with nobody watching the terminal. Those logs become the only witness you have to what actually ran, which is why they were worth the care.

Quick check
01You wrap a network call in a retry loop with exponential backoff: `for attempt in range(4)`, and on failure you `time.sleep(2 ** attempt)` and try again. But you never set a per-call timeout, and one day the server accepts your connection and then goes silent forever. What happens?
Correct — Retries only trigger on an exception. A silent server produces no exception, so the loop is frozen on attempt one, and a timeout is exactly what converts a hang into a retryable error.
Incorrect — range(4) caps the number of iterations, not the time spent inside one of them. If a single call hangs, the loop never reaches its next iteration to count down.
Incorrect — There is no default network timeout in Python; sockets and requests block indefinitely unless you set one explicitly.
Incorrect — time.sleep runs between attempts, only after an exception has been caught. It does nothing to a call that is currently hung and has raised nothing.
02What is the practical difference between a bare `except:` and `except Exception:`?
Incorrect — a bare except is strictly wider, catching things that except Exception deliberately leaves alone.
Correct — bare except catches BaseException, so it can eat your Ctrl-C and a requested shutdown, while except Exception spares both.
Incorrect — speed is not the difference; the difference is which exceptions each one is willing to catch.
Incorrect — a SyntaxError is raised before the code runs and is an ordinary Exception subclass anyway, so this is not the distinction.
03In the check_ip.py example, warnings and info go through logging (stderr) and the result goes through print (stdout). You run `python3 check_ip.py | downstream`, and the feed times out twice before answering on the third try. What does `downstream` receive on its standard input?
Incorrect — the log lines are written to stderr, which a pipe does not carry, so they do not reach downstream.
Incorrect — the retry loop recovers on the third attempt, so the program prints its result normally.
Correct — a pipe carries stdout only, and the lesson keeps data on stdout and diagnostics on stderr so downstream sees clean output.
Incorrect — warnings are not routed to stdout, and severity does not decide which stream a message uses.

Related