PYTH-240 · Lesson 11 of 12 · 26 min

Scheduling and running scripts reliably

▶ Video coming soon A video version of this lesson is on the way.

A script that works when Jordan runs it at his desk is only the beginning. The Monday report now runs at 06:30 every week without anyone watching, the order sync runs every fifteen minutes, and the arrivals check runs every morning. Unattended scripts that talk to networks fail in ways desk-side testing never shows: the shop API is briefly down during a deployment, the carrier rate-limits a burst of requests, the Wi-Fi drops for ten seconds, the server reboots in the middle of a run, or one run is still going when the next one starts. A reliable script expects all of this. It finishes the job when it can, fails loudly when it cannot, never does the same work twice in a harmful way, and tells someone when it has stopped running altogether.

PYTH-120 introduced the tools that start scripts on a schedule: cron and systemd timers on Linux and Task Scheduler on Windows. This lesson is about what happens inside the scheduled script: timeouts, retries with exponential backoff, deciding which failures are worth retrying, checkpoints that let a job resume where it stopped, locks that prevent overlapping runs, time zones, and the monitoring that notices when a job silently stops.

A quick reminder: scheduling tools

cron runs a command at times described by five fields: minute, hour, day of month, month and day of week, so 30 6 * * 1 means 06:30 every Monday. systemd timers do the same with more control, including Persistent=true, which runs a job that was missed while the machine was off. Windows Task Scheduler offers similar triggers through its interface or PowerShell. Cloud platforms have their own schedulers that start a container or a function on a timetable. Whichever you use, the same traps from PYTH-120 apply: the job runs with a minimal environment, a different working directory and no terminal, so it needs full paths, explicit settings and somewhere to send its output.

The crontab on the shop server (not Python)
# m   h  dom mon dow  command
30    6   *   *   1    /srv/ridgeline/bin/run weekly-report --email theo@ridgelinecoffee.example
*/15  *   *   *   *    /srv/ridgeline/bin/run order-sync
10    7   *   *   *    /srv/ridgeline/bin/run arrivals-check --notify chat

Timeouts on everything

The first rule of unattended networking is that every operation that waits must have a limit. A request without a timeout to a server that accepts the connection and then never answers will wait for hours, and the next scheduled run will start beside it. requests takes timeout=; sockets take settimeout; smtplib.SMTP takes timeout=; subprocess.run takes timeout=. A job as a whole should also have a deadline: the scheduler can kill a job that runs too long, and the script can check how long it has been running and stop cleanly before then. Choosing values is a judgement: long enough for a slow day, short enough that a hung connection is noticed. Ten seconds for an API call and twenty minutes for the whole report are Jordan's defaults.

Retrying transient failures

Many failures are transient: they go away if you wait a moment. A connection reset, a timeout, a 503 during a deployment and a 429 from a rate limit usually succeed on a second or third try. Others are permanent: a 401 from a revoked key, a 404 for a wrong URL or a 422 for invalid data will fail every time, and retrying them only wastes time and hides the real problem. A good retry policy therefore decides first whether an error is retryable, and only then retries, a limited number of times.

It also matters whether the operation is safe to repeat. Lesson 2 explained that GET, PUT and DELETE are idempotent and POST is not, unless the API supports idempotency keys. A script may retry a GET or a PATCH that sets a stock count automatically; it should retry a POST that creates a shipment only with an idempotency key.

Exponential backoff with jitter

How long to wait between attempts matters. Retrying immediately, again and again, hammers a service that is already struggling and makes the outage worse. The standard approach is exponential backoff: wait a short time after the first failure, then double the wait after each further failure, up to a maximum. With a base of one second and a cap of 30, the waits are 1, 2, 4, 8, 16, 30, 30 seconds. If the server sends Retry-After, use that value instead.

When many clients fail at the same moment, for example every shop that uses the same payment processor during an outage, plain exponential backoff makes them all retry at the same instants, in synchronized waves. Adding jitter, a random part of the wait, spreads them out. A common form, called full jitter, waits a random time between zero and the exponential value. The listing shows the schedule with and without jitter; a seeded random generator makes the output the same every run, which is also how tests check retry logic.

Exponential backoff with a cap, and with full jitter
import random

def backoff(attempt, base=1.0, cap=30.0):
    """Delay before retry number `attempt` (1 for the first retry)."""
    return min(cap, base * 2 ** (attempt - 1))

def full_jitter(attempt, rng, base=1.0, cap=30.0):
    return rng.uniform(0, backoff(attempt, base, cap))

rng = random.Random(240)
print("plain: ", [backoff(a) for a in range(1, 8)])
print("jitter:", [round(full_jitter(a, rng), 1) for a in range(1, 8)])
print("total plain wait for 7 retries:", sum(backoff(a) for a in range(1, 8)), "seconds")
Output
plain:  [1.0, 2.0, 4.0, 8.0, 16.0, 30.0, 30.0]
jitter: [0.9, 0.1, 3.8, 2.5, 9.9, 15.8, 19.3]
total plain wait for 7 retries: 91.0 seconds
A horizontal time axis from 0 to 60 seconds. Attempt markers: attempt 1 fails at 0; waits of 1, 2, 4, 8 and 16 seconds are drawn as growing bars, with attempts 2 to 5 failing in red and attempt 6 succeeding in green at about 31 seconds. Below, three clients without jitter retry at exactly the same instants, drawn as stacked dots; three clients with jitter retry at scattered times.
Exponential backoff spaces retries further apart each time; jitter stops many clients from retrying in lockstep
A retry helper that retries only transient errors, with backoff and jitter
import random

class TransientError(Exception): pass
class PermanentError(Exception): pass

def with_retries(operation, attempts=5, base=1.0, cap=30.0, sleep=None, rng=None, log=print):
    rng = rng or random.Random()
    for attempt in range(1, attempts + 1):
        try:
            return operation()
        except PermanentError:
            raise                                       # retrying will not help
        except TransientError as e:
            if attempt == attempts:
                raise
            delay = rng.uniform(0, min(cap, base * 2 ** (attempt - 1)))
            log(f"attempt {attempt} failed ({e}); retrying in {delay:.1f} s")
            sleep(delay)

outcomes = iter([TransientError("503"), TransientError("timeout"), "42 orders"])
def flaky():
    result = next(outcomes)
    if isinstance(result, Exception):
        raise result
    return result

waited = []
print(with_retries(flaky, sleep=waited.append, rng=random.Random(1)))
print("waited", [round(w, 2) for w in waited])
Output
attempt 1 failed (503); retrying in 0.1 s
attempt 2 failed (timeout); retrying in 1.7 s
42 orders
waited [0.13, 1.69]

The helper takes the operation as a function, which keeps the retry logic in one place for every kind of call. It distinguishes the two kinds of failure with exception classes; in a real client, the code that inspects a response raises TransientError for timeouts, connection errors, 429 and 5xx, and PermanentError for other 4xx codes. sleep and rng are parameters, so the listing runs instantly and the same way every time. For production code, the tenacity library offers the same ideas as decorators, and requests can be configured to retry with urllib3's Retry class.

Try it yourself

The backoff schedule

Write delays(retries, base, cap) that returns the list of waits, in seconds, before each of retries retries using exponential backoff: base, then twice that, then four times, and so on, but never more than cap.

def delays(retries, base, cap):
    return [base] * retries

print(delays(7, 1, 30))

Try it yourself

Retry or give up?

Write should_retry(method, status, has_key=False) that returns True only when a failed request is worth retrying automatically. The status is retryable if it is 429 or any 5xx code, or None (meaning a timeout or connection error with no response). The method must also be safe to repeat: GET, PUT or DELETE always are; POST and PATCH only when has_key is True (an idempotency key was sent).

def should_retry(method, status, has_key=False):
    return status != 200

print(should_retry('POST', 503))

Checkpoints: resume instead of restart

A job that processes many items should remember how far it got. If the order sync handles orders 5001 to 5100 and the server reboots at order 5047, the next run should start at 5048, not at 5001, and certainly not skip the rest. The usual technique is a checkpoint: after each item, or each page, the job saves a small piece of state, such as the last order ID processed or the API's pagination cursor, and on start-up it reads the checkpoint and continues from there. The checkpoint must be written atomically, with the write-to-a-temporary-file-then-rename pattern from PYTH-120, so a crash in the middle of saving cannot leave a corrupted file that stops every later run.

A checkpoint lets the second run continue where the first stopped
import json, os, tempfile

def save_checkpoint(path, state):
    fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path) or ".")
    with os.fdopen(fd, "w") as f:
        json.dump(state, f)
    os.replace(tmp, path)                       # atomic: old file or new file, never half

def load_checkpoint(path):
    try:
        with open(path) as f:
            return json.load(f)
    except FileNotFoundError:
        return {"last_order": 0}

def sync(orders, path, crash_after=None):
    state = load_checkpoint(path)
    done = 0
    for order_id in orders:
        if order_id <= state["last_order"]:
            continue                            # already handled in an earlier run
        if crash_after is not None and done == crash_after:
            raise RuntimeError("power cut")
        print("syncing", order_id)
        state["last_order"] = order_id
        save_checkpoint(path, state)
        done += 1

if os.path.exists("sync-state.json"):
    os.remove("sync-state.json")             # start fresh each time this listing runs
orders = [5045, 5046, 5047, 5048, 5049]
try:
    sync(orders, "sync-state.json", crash_after=3)
except RuntimeError as e:
    print("run 1 stopped:", e)
sync(orders, "sync-state.json")
print(load_checkpoint("sync-state.json"))
Output
syncing 5045
syncing 5046
syncing 5047
run 1 stopped: power cut
syncing 5048
syncing 5049
{'last_order': 5049}

Checkpoints work hand in hand with idempotent operations. There is always a small window between doing an item and saving the checkpoint; if the crash falls in that window, the item will be done again on the next run. Marking an order packed twice is harmless, which is why the sync uses PATCH to a fixed status. Creating a shipment twice is not, which is why that step sends an idempotency key derived from the order number.

Try it yourself

Only the new orders

Write new_orders(orders, checkpoint) that takes a list of order dictionaries (each with an integer "id", not necessarily sorted) and a checkpoint dictionary {"last_order": N}. Return a tuple (todo, new_checkpoint): the orders with an id greater than N, sorted by id, and a new checkpoint whose last_order is the highest id seen (or the old value if there are no new orders). Do not modify the checkpoint passed in.

def new_orders(orders, checkpoint):
    return orders, checkpoint

print(new_orders([{'id': 5048}, {'id': 5046}, {'id': 5050}], {'last_order': 5047}))

Preventing overlapping runs

If the order sync runs every fifteen minutes and one run takes twenty because the shop API is slow, the next run starts while the first is still going. Two copies processing the same orders at once can double-count stock or race to write the checkpoint. The simple protection is a lock: at start-up, the job tries to create a lock file that must not already exist, and if the file is there, it exits quietly because another run is in progress. Creating the file with os.O_CREAT | os.O_EXCL makes the check and the creation a single atomic step, so two runs starting at the same instant cannot both succeed. On Linux, the flock command from PYTH-120 achieves the same from the crontab line.

An atomic lock file prevents two runs at once
import os

def acquire(path):
    try:
        fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
    except FileExistsError:
        return False
    os.write(fd, str(os.getpid()).encode())
    os.close(fd)
    return True

def release(path):
    os.remove(path)

LOCK = "order-sync.lock"
print("first run gets the lock:", acquire(LOCK))
print("second run gets the lock:", acquire(LOCK))
release(LOCK)
print("after release:", acquire(LOCK))
release(LOCK)
Output
first run gets the lock: True
second run gets the lock: False
after release: True

A lock file has one weakness: if a run crashes without removing it, every later run will think a run is in progress. Robust versions store the process ID and start time in the file and treat a lock older than the longest plausible run as stale, or use operating-system locks such as fcntl.flock, which the system releases automatically when the process ends. Whatever the method, a job that skips because of a lock should log that it did, so a stuck lock is noticed within a run or two.

Time zones and daylight saving time

Scheduled jobs live in time zones, and twice a year in most of Canada and the United States the clocks change. A job scheduled at 02:30 local time is skipped on the night the clocks spring forward, because that time does not exist, and may run twice on the night they fall back. Schedule jobs at times that are not affected, such as 06:30, or run the scheduler in UTC. Inside the script, store and compare times in UTC with timezone-aware datetime objects and convert to local time only for display, using zoneinfo.ZoneInfo with the right zone name.

The same local run time is a different UTC time in winter and summer (zone data may not be available in the browser)
from datetime import datetime, timezone
from zoneinfo import ZoneInfo

local = ZoneInfo("America/Vancouver")
run_utc = datetime(2026, 11, 9, 14, 30, tzinfo=timezone.utc)
print(run_utc.astimezone(local))           # 2026-11-09 06:30:00-08:00
summer = datetime(2026, 7, 6, 13, 30, tzinfo=timezone.utc)
print(summer.astimezone(local))            # 2026-07-06 06:30:00-07:00

Monitoring: noticing the job that stopped

Logging failures is not enough, because the worst failure produces no log at all: the job never runs. The server was replaced and nobody copied the crontab, the virtual environment broke after an upgrade, or the scheduler itself stopped. The fix is a heartbeat, sometimes called a dead man's switch: at the end of every successful run, the job records the time of its success, for example by pinging a monitoring service's URL or writing a timestamp file, and a separate check, ideally on another system, alerts if no success has been recorded for longer than the expected interval plus a grace period. The alert then arrives even when the job has vanished completely.

Checking each job's last success against its interval and grace period
def overdue(last_success, interval, grace, now):
    """True if the job has not succeeded within interval + grace seconds."""
    return last_success is None or now - last_success > interval + grace

HOUR = 3600
checks = [("order-sync", 1762700000, 15 * 60, 10 * 60),
          ("weekly-report", 1762150000, 7 * 24 * HOUR, 2 * HOUR),
          ("arrivals-check", None, 24 * HOUR, HOUR)]
now = 1762702000
for name, last, interval, grace in checks:
    print(f"{name:15} {'ALERT: overdue' if overdue(last, interval, grace, now) else 'ok'}")
Output
order-sync      ALERT: overdue
weekly-report   ok
arrivals-check  ALERT: overdue

Try it yourself

Is the job overdue?

Write status(last_success, interval, grace, now) that returns "ok" if the job succeeded within interval seconds, "late" if it is past the interval but still within the grace period, and "overdue" if it is past interval + grace or has never succeeded (last_success is None).

def status(last_success, interval, grace, now):
    return 'ok' if now - last_success < interval else 'overdue'

print(status(1000, 900, 600, 2000))

A runbook for every scheduled job

Every unattended job should have a short runbook, a page that says what the job does, when it runs, what it depends on, where its logs are, how to tell whether it worked, and what to do when it fails, including whether it is safe to run again by hand. Jordan keeps them in the ridgeline-tools repository under docs/runbooks. When the order sync alerted on a Saturday while Jordan was away, Sam followed the runbook, saw from the log that the shop API had returned 503 for an hour, confirmed on the shop's status page that it was a known outage, and re-ran the sync by hand once it ended. The checkpoint meant nothing was processed twice.

Common mistakes

  • MistakeLeaving out timeouts in unattended scripts.

    InsteadSet a timeout on every network call and a deadline on the job as a whole.

  • MistakeRetrying every error immediately in a tight loop.

    InsteadRetry only transient errors, with exponential backoff, jitter and a limit.

  • MistakeRetrying a POST that creates something without an idempotency key.

    InsteadRetry only repeatable requests, or add an idempotency key.

  • MistakeRestarting a long job from the beginning after a crash.

    InsteadSave an atomic checkpoint and resume from it.

  • MistakeLetting a new run start while the previous one is still running.

    InsteadTake an atomic lock at start-up and exit if it is held.

  • MistakeRelying on error logs to notice a job that stopped running.

    InsteadRecord each success and alert when none arrives within the interval plus a grace period.

Summary and key terms

  • Unattended scripts face outages, rate limits, crashes, overlaps and clock changes, and must handle them on purpose.
  • Every wait needs a timeout, and every job a deadline.
  • Retry only transient failures (timeouts, connection errors, 429, 5xx) on repeatable requests, a limited number of times.
  • Exponential backoff doubles the wait up to a cap; jitter spreads out clients; Retry-After overrides both.
  • Atomic checkpoints let a job resume where it stopped; idempotent steps make the small overlap harmless.
  • Atomic lock files or OS locks prevent overlapping runs; stale locks must be detected.
  • Schedule around daylight saving changes, work in UTC, and monitor with heartbeats, a grace period and a runbook.
transient failure
An error that is likely to succeed if retried after a short wait.
permanent failure
An error that will happen again every time until something is fixed.
exponential backoff
Doubling the wait between retries, up to a cap.
jitter
A random part of a retry delay that stops clients retrying together.
checkpoint
Saved progress that lets a job resume after stopping.
lock file
A file whose existence shows that a job is already running.
stale lock
A lock left behind by a run that ended without removing it.
heartbeat
A record of each successful run, checked by a separate monitor.
grace period
Extra time allowed before a late job is reported.
runbook
A short document explaining how to operate and fix a job.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. List the first five backoff delays with a base of 2 seconds and a cap of 20.

    Show answer

    2, 4, 8, 16, 20

  2. Should a script retry a 401 automatically?

    Show answer

    No; the credential is wrong and every retry will fail the same way.

  3. Why must a checkpoint be written atomically?

    Show answer

    A crash during the write must not leave a corrupted file that breaks later runs.

  4. What problem does O_CREAT | O_EXCL solve for lock files?

    Show answer

    It makes checking and creating the lock one atomic step, so two runs cannot both get it.

  5. Why is 02:30 a bad time for a daily job in most of North America?

    Show answer

    It is skipped when clocks spring forward and can repeat when they fall back.

  6. A job runs every 15 minutes with a 10-minute grace period. When should the monitor alert?

    Show answer

    When no success has been recorded for more than 25 minutes.

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz