open atlas
↑ Back to track
Python for JS/TS developers PY · 02 · 05

Robust CLI scripts: exit codes, logging, retries, signals

A script that runs unattended in cron or CI lives or dies by four contracts: a non-zero exit on failure, logs to stderr instead of print, bounded backoff retries for transient I/O, and signal-safe atomic writes so a kill never ships a truncated file.

PY Senior ◷ 18 min
Level
FoundationsJuniorMiddleSenior

The nightly export had run green for months. Then the upstream API started returning 500s, and the script “handled” it — the body was wrapped in try: ... except Exception: print("export failed"), and after printing, control fell off the end of main and the process exited. Exited zero. cron saw exit 0, marked the job successful, and sent no alert. The downstream dashboard kept reading yesterday’s file because today’s never got written, and “yesterday’s” slowly became “two weeks ago’s.” Nobody was paged. The failure was discovered when an analyst noticed the file’s modification date hadn’t moved in fourteen days. Every byte of that incident is preventable, and every fix is something a script running unattended must do by default: exit non-zero, log to stderr, retry the transient blip, and write atomically.

Exit codes: the contract with the OS

Every automation system — cron, CI, Kubernetes, Makefiles — makes one assumption about your script: exit 0 means it worked, non-zero means it didn’t. When you break that contract, you break the whole alerting chain. A process reports success or failure with exactly one number: its exit code. 0 means success; anything non-zero means failure. That number is not decoration — it is the only thing cron, CI runners, && chains, systemd, and every orchestrator look at to decide whether the next step runs or an alert fires. backup.sh && upload.sh runs upload only if backup exits 0. CI marks a step red only on non-zero. So the exit code is the success signal, and getting it wrong is how a failed job reports green.

import sys, logging

log = logging.getLogger("export")

def main() -> int:
    try:
        data = fetch_export()      # may raise on a 500
        write_atomic("/data/export.json", data)
        return 0                   # success — and only here
    except Exception:
        log.exception("export failed")  # ERROR + traceback to stderr
        return 1                   # FAILURE is now visible to cron/CI

if __name__ == "__main__":
    sys.exit(main())              # the return value becomes the exit code

The deadly pattern is the inverse: catch the exception, print a message, and let main fall off the end. A function that falls off the end returns None; sys.exit(None) exits 0. You have just told the OS the job succeeded while it actually failed. Either let the exception propagate (Python exits non-zero with a traceback) or explicitly sys.exit(1). Reserve distinct codes (2, 3, …) only if a caller actually branches on which failure — otherwise 1 is plenty.

Why this works

Why is except Exception: print("error") then falling through one of the most dangerous patterns in an unattended script? Because it converts a failure into a SUCCESS as far as the OS is concerned: the process exits 0, so cron, CI, and monitoring all see green, no alert fires, and the failure is invisible until someone notices stale output days later. print also lands on stdout — often discarded, or worse, mixed into piped data — with no level and no timestamp, so even the message is lost. Robustness requires the failure to be LOUD and MACHINE-VISIBLE: log at ERROR to stderr and exit non-zero, so the thing watching the script actually learns that it failed.

Logging, not print: the unattended-script rule

print writes to stdout with no level, no timestamp, and no routing control. In an interactive session that’s fine; in a cron job it’s invisible — there’s no terminal, and stdout is often captured as the job’s data or discarded. The logging module gives you what an unattended script needs: levels (DEBUG/INFO/WARNING/ERROR), timestamps, and a destination configured once. The two rules that matter: diagnostics go to stderr, real output goes to stdout (so a downstream | jq sees clean data, not your log lines), and the level is set from a flag or env var so you can dial up DEBUG without editing code.

import logging, os, sys

logging.basicConfig(
    level=os.environ.get("LOG_LEVEL", "INFO"),
    format="%(asctime)s %(levelname)s %(name)s %(message)s",
    stream=sys.stderr,            # diagnostics to stderr; stdout stays clean for data
)
log = logging.getLogger("export")

log.info("starting export run")          # routine progress
log.error("upstream returned 503")       # a problem worth alerting on
log.exception("unexpected failure")      # ERROR + full traceback, inside an except block

log.exception is the one to reach for inside an except block — it logs at ERROR and attaches the traceback, which is exactly what you want shipped to your log aggregator. When you see a cron job with print statements for diagnostics, ask yourself: where does that output actually go, and who is reading it at 3am when the job fails? print-debugging a cron job produces nothing greppable; structured logs are searchable, shippable, and tie straight into observability.

Retries with backoff: surviving transient I/O

Network and API calls fail transiently: a timeout, a 503, a rate-limit. The robust response is not “give up” and not “retry forever” — it’s a bounded retry with exponential backoff and jitter. Wait ~1s, then ~2s, then ~4s, each with a random component so a fleet of scripts doesn’t retry in lockstep and stampede the recovering service, up to a cap of attempts. Two hard constraints: only retry idempotent or transient operations, and respect Retry-After when the server sends it.

import time, random, logging

log = logging.getLogger("export")

def with_backoff(op, *, attempts=5, base=1.0, cap=30.0):
    for i in range(attempts):
        try:
            return op()
        except TransientError as e:           # ONLY transient/retryable errors
            if i == attempts - 1:
                raise                          # budget exhausted -> let it fail
            delay = min(cap, base * 2 ** i) + random.uniform(0, 1)  # backoff + jitter
            log.warning("attempt %d failed (%s), retrying in %.1fs", i + 1, e, delay)
            time.sleep(delay)

The asymmetry is the whole point. A bounded backoff turns a transient 503 into a non-event — the third attempt succeeds and nobody notices. An unbounded or instant retry does the opposite: it hammers an already-struggling service, turning a blip into an outage. And retrying a non-idempotent POST (charge a card, send an email) can double-apply the side effect — so retry reads and idempotent writes, and make non-idempotent operations idempotent with a key before you retry them.

Signals and atomic writes: dying cleanly

An unattended process gets killed. The orchestrator sends SIGTERM to stop it; a human sends SIGINT with Ctrl-C. By default both unwind the stack — but if you’re halfway through writing a file, the default leaves a truncated file, a held lock, or a temp dir full of garbage. Two complementary defenses: handle the signal (or rely on try/finally + context managers) to flush and clean up, and never write the real file in place — write a temp file, then os.replace, which is atomic on a POSIX filesystem. A kill at any instant leaves either the old complete file or the new complete file, never a half-written one.

import os, signal, tempfile, json

def write_atomic(path: str, obj) -> None:
    d = os.path.dirname(path) or "."
    fd, tmp = tempfile.mkstemp(dir=d)          # same filesystem -> rename is atomic
    try:
        with os.fdopen(fd, "w") as f:
            json.dump(obj, f)
            f.flush()
            os.fsync(f.fileno())               # durable before the swap
        os.replace(tmp, path)                  # atomic: readers see old OR new, never partial
    except BaseException:
        os.unlink(tmp)                         # killed mid-write -> no orphan temp
        raise

def handle_term(signum, frame):
    raise SystemExit(143)                      # turn SIGTERM into a clean unwind (128+15)

signal.signal(signal.SIGTERM, handle_term)     # finally/with blocks now run on stop

Because os.replace is atomic, the kill window where a partial file could be observed simply doesn’t exist. Combine that with idempotency — a script will be re-run (a retry, a manual rerun, an overlapping cron tick), so check-before-act, write atomically, and take a lockfile to refuse a second concurrent run. An idempotent script you can safely run twice; a non-idempotent one corrupts state the second time.

Pick the best fit

You own a nightly cron export that hits a flaky upstream API and writes a JSON file consumed by a dashboard. How do you make it robust so a failure is never silent and a kill never ships a truncated file?

Quiz

A cron script catches an exception, prints 'export failed', and falls off the end of main without calling sys.exit. What does cron conclude, and why?

Quiz

Which operations are safe to wrap in a backoff-retry loop, and what cleanup must a script do so a SIGTERM never ships a corrupt output?

Recall before you leave
  1. 01
    Explain why catch-print-return-0 is the most dangerous pattern in an unattended script, and exactly what to do instead.
  2. 02
    Walk through the four robustness contracts for an unattended script: logging, retries, signals, and idempotency — with the specific mechanism for each.
Recap

A script that runs unattended in cron or CI is judged by four contracts, every one of which the silent-export incident violated. First, the exit code is the contract with the OS: 0 is success, non-zero is failure, and cron, CI, and &&-chains branch only on that number — so the deadly pattern is catch the exception, print a message, and fall off the end of main, which returns None, exits 0, and reports a failure as green with no alert; the fix is to let the exception propagate or sys.exit(1). Second, use logging, not print: diagnostics to stderr with levels and timestamps, real data to stdout, level from a flag or env, and log.exception inside an except to ship the traceback — print-debugging is invisible in a cron job. Third, retry transient I/O with bounded exponential backoff plus jitter up to a cap, but only for transient/idempotent operations and respecting Retry-After, because an unbounded or instant retry stampedes a recovering service and retrying a non-idempotent POST double-applies. Fourth, die cleanly: handle SIGTERM and SIGINT (or use try/finally and context managers) to flush and clean up, and write to a temp file then os.replace so a kill at any instant leaves the old or new whole file, never a truncated one — and make the script idempotent with check-before-act, atomic writes, and a lockfile so an inevitable re-run can’t double-process or corrupt state. Together: fail loud, log to stderr, retry bounded, write atomically. Now when you see a cron job that catch-print-returns-0, you’ll know exactly which silent fourteen-day failure it is hiding.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.