Skip to main content

Python Control Flow: if, match, for and while Loops with Retries

Python if, elif and match, for loops over hosts and batches, while loops with deadlines, and exponential backoff with jitter, with real output.

Scripts make decisions and repeat work: skip a host that is in maintenance, retry a request that timed out, restart servers two at a time and stop at the first failure. That is control flow: if and match choose what runs, and for and while repeat it.

This part covers conditions and the truthiness rules behind them, the match statement (Python 3.10 and later), for loops over lists, ranges and batches, and while loops that wait for a service with a deadline and exponential backoff.

Prerequisites

  • The ~/pyops project from Part 1, with its virtual environment active.
  • Strings, numbers and environment variables from Part 2.

if, elif and else

An if statement runs its block when the condition is true. elif (else if) adds more conditions, checked in order, and else catches everything left. The block is the indented lines after the colon; Python uses the indentation, not braces, so it must be consistent (four spaces is the standard):

"""Check a service's settings before it starts."""

import os
import sys

port = int(os.environ.get("APP_PORT", "8080"))
workers = int(os.environ.get("APP_WORKERS", "4"))
env = os.environ.get("APP_ENV", "dev")

if not 1 <= port <= 65535:
    sys.exit(f"APP_PORT must be between 1 and 65535, got {port}")
elif port < 1024:
    print(f"warning: port {port} needs root or CAP_NET_BIND_SERVICE")

if workers < 1:
    sys.exit("APP_WORKERS must be at least 1")

if env == "prod" and workers < 2:
    sys.exit("prod needs at least 2 workers")

mode = "production" if env == "prod" else "development"
print(f"settings ok: port={port} workers={workers} env={env} ({mode} mode)")

A few things in that script are worth reusing:

  • Chained comparisons: 1 <= port <= 65535 means 1 <= port and port <= 65535.
  • sys.exit("message") prints the message to standard error and exits with code 1, so a shell script or CI job sees the failure. sys.exit() with no argument, or with 0, exits successfully.
  • A conditional expression, a if condition else b, picks one of two values in a single line. Use it for simple choices only.

Indentation is part of the syntax, so a missing indent is an error before anything runs:

Conditions, and and or

Conditions use ==, !=, <, <=, >, >=, in (membership), is (identity, for None) and not. Combine them with and and or, which short-circuit: or stops at the first true value and and at the first false one. They also return that value, not just True or False, which gives you a compact way to apply defaults:

Two traps in that transcript:

  • retries or 3 gives 3 when you asked for 0 retries, because 0 is false. Use or for defaults only when the empty value really means "not set"; otherwise test is None.
  • status == 500 or 503 does not mean "500 or 503". It means (status == 500) or 503, which is always true. Write status in (500, 503).

match: Choose by Shape

The match statement (Python 3.10 and later) compares a value against patterns. Each case can match a literal, several alternatives with |, a guard with if, or the shape of a list or dictionary, and it can pull values out of it at the same time. Here it classifies HTTP status codes:

"""Decide what to do with an HTTP status code."""

for code in [200, 204, 301, 404, 429, 500, 503]:
    match code:
        case 200 | 201 | 204:
            action = "ok"
        case 301 | 302 | 307 | 308:
            action = "follow the redirect"
        case 429 | 502 | 503 | 504:
            action = "retry later"
        case _ if 400 <= code < 500:
            action = "client error: fix the request, do not retry"
        case _:
            action = "server error: alert"
    print(f"{code}: {action}")

case _: matches anything, like else. Patterns really pay off with structured data. A health endpoint might return one of several JSON shapes, and match both recognizes the shape and extracts the fields:

"""Turn health check responses of different shapes into one line each."""

import json

responses = [
    '{"status": "ok", "version": "1.4.2"}',
    '{"status": "degraded", "failing": ["db", "cache"]}',
    '{"error": "timeout", "after_ms": 2000}',
    '{"status": "maintenance"}',
]

for text in responses:
    match json.loads(text):
        case {"status": "ok", "version": str(version)}:
            print(f"healthy, version {version}")
        case {"status": "degraded", "failing": [*parts]}:
            print(f"degraded: {', '.join(parts)} failing")
        case {"error": message, "after_ms": int(ms)}:
            print(f"check failed: {message} after {ms} ms")
        case other:
            print(f"unknown response: {other}")

A dictionary pattern matches when the listed keys exist with matching values; extra keys are ignored. str(version) matches only a string and binds it to version, and [*parts] matches any list. The last case binds the whole value to other.

match also works on a list of words, which makes a small command dispatcher readable. sys.argv holds the script's command-line arguments (sys.argv[0] is the script name):

"""A tiny command dispatcher: python deployctl.py deploy staging --dry-run"""

import sys

ENVIRONMENTS = ("dev", "staging", "prod")

match sys.argv[1:]:
    case ["deploy", env] if env in ENVIRONMENTS:
        print(f"deploying to {env}")
    case ["deploy", env, "--dry-run"] if env in ENVIRONMENTS:
        print(f"would deploy to {env}")
    case ["deploy", env, *_]:
        sys.exit(f"unknown environment {env!r}, choose from {', '.join(ENVIRONMENTS)}")
    case ["rollback", release] if release.isdigit():
        print(f"rolling back to release {int(release)}")
    case ["rollback", release]:
        sys.exit(f"release must be a number, got {release!r}")
    case ["status"] | []:
        print("all services healthy")
    case [command, *_]:
        sys.exit(f"unknown command {command!r}")

The cases are tried from top to bottom, so put specific patterns before general ones. For a real command-line tool, use argparse, which also gives you --help and error messages; Part 10 builds one.

for Loops

A for loop runs its block once for each item of a sequence: a list, a string, the lines of a file, the keys of a dictionary. Three helpers cover most needs: enumerate() adds a counter, zip() walks several lists side by side, and range() produces numbers:

zip(..., strict=True) (Python 3.10 and later) raises an error when the lists have different lengths, instead of silently dropping the extra items.

break leaves the loop early and continue skips to the next item. A loop can also have an else block, which runs only when the loop finished without break. It reads oddly at first, but it is exactly what a search needs:

Roll Out in Batches

Here is a pattern you will write in some form for any rollout: restart hosts in batches, check each one, and stop everything at the first failure. range(0, len(hosts), batch_size) gives the start index of each batch, and a slice takes the batch. The health results are hard-coded so you can see the control flow; Part 8 replaces them with real HTTP checks:

"""Restart hosts in batches and stop at the first failed health check."""

hosts = ["web-01", "web-02", "web-03", "web-04", "web-05"]
healthy_after_restart = {
    "web-01": True,
    "web-02": True,
    "web-03": False,
    "web-04": True,
    "web-05": True,
}
batch_size = 2

failed = None
for start in range(0, len(hosts), batch_size):
    batch = hosts[start : start + batch_size]
    print(f"batch {start // batch_size + 1}: {', '.join(batch)}")
    for host in batch:
        if not healthy_after_restart[host]:
            print(f"  {host}: FAILED health check")
            failed = host
            break
        print(f"  {host}: healthy")
    if failed:
        break

if failed:
    untouched = len(hosts) - hosts.index(failed) - 1
    print(f"rollout stopped at {failed}, {untouched} hosts not restarted")
else:
    print("rollout complete")

break only leaves the innermost loop, so the outer loop checks failed and breaks as well. When nested loops get hard to follow, move the inner loop into a function that returns early; Part 4 does that.

while Loops: Wait for a Service

A while loop repeats as long as its condition is true. Its classic job in operations code is waiting: for a port to open, a deployment to finish, a file to appear. Every such loop needs two things: a deadline, so it cannot run forever, and a sleep between attempts, so it does not hammer the service or burn a CPU core.

This script waits until a TCP port accepts connections. time.monotonic() is a clock that never jumps (unlike the wall clock, which NTP can move), so it is the right one for measuring timeouts. try and except OSError catch the error that a refused connection raises; Part 8 covers exceptions in detail:

"""Wait until a TCP port accepts connections: wait_for_port.py HOST PORT [TIMEOUT]"""

import math
import socket
import sys
import time

host = sys.argv[1]
port = int(sys.argv[2])
timeout = float(sys.argv[3]) if len(sys.argv) > 3 else 30.0
if not 1 <= port <= 65535:
    sys.exit(f"PORT must be between 1 and 65535, got {port}")
if not math.isfinite(timeout) or timeout <= 0:  # float() also accepts "nan" and "inf"
    sys.exit(f"TIMEOUT must be a positive number of seconds, got {timeout}")

start = time.monotonic()
deadline = start + timeout
delay = 0.5
attempt = 0
last_error = None
while True:
    remaining = deadline - time.monotonic()
    if remaining <= 0:
        sys.exit(f"{host}:{port} still down after {attempt} attempts: {last_error}")
    attempt += 1
    try:
        # one attempt never gets more than the time that is left
        with socket.create_connection((host, port), timeout=min(1.0, remaining)):
            break
    except OSError as err:
        last_error = err
        wait = min(delay, deadline - time.monotonic())
        if wait > 0:
            print(f"attempt {attempt}: {err}, retrying in {wait:.1f}s")
            time.sleep(wait)
        delay = min(delay * 2, 5.0)

print(f"{host}:{port} is up after {attempt} attempts ({time.monotonic() - start:.1f}s)")

The first checks reject a port outside 1 to 65535 and a timeout such as nan or inf, with which the deadline would never arrive. To try it, start a web server that only begins listening after three seconds, in the background, and wait for it. Then wait for a port where nothing will ever listen, and pass a bad timeout:

The delay doubles after each failed attempt (0.5, 1, 2 seconds, up to a cap of 5): this is exponential backoff. The loop checks the deadline before every attempt, the last sleep is cut short at the deadline, and each connection attempt gets at most the time that is left. Two things can still stretch it: looking up a host name has its own resolver timeouts, and for a name with several addresses create_connection() tries each address with that timeout. For an IP address, as here, the deadline holds.

Backoff with Jitter

When many clients retry on the same schedule, they all hit the recovering service at the same moments. Adding randomness, jitter, spreads them out. The common "full jitter" approach waits a random time between zero and the exponential ceiling:

"""Print a retry schedule with exponential backoff and full jitter."""

import random

random.seed(7)  # a fixed seed makes this run repeatable; leave it out in real code
base, cap = 0.5, 30.0

total = 0.0
for attempt in range(1, 9):
    ceiling = min(cap, base * 2 ** (attempt - 1))
    delay = random.uniform(0, ceiling)
    total += delay
    print(f"attempt {attempt}: ceiling {ceiling:5.1f}s, wait {delay:5.2f}s")
print(f"worst case {sum(min(cap, base * 2**n) for n in range(8)):.1f}s, this run {total:.1f}s")

Retry only what is safe to repeat (a GET, a health check, an idempotent API call), and only on errors that can go away: timeouts, connection errors, 429 and 5xx responses. A 404 will not fix itself. Part 4 wraps this loop in a reusable retry decorator, and Part 8 applies it to real HTTP calls.

The Walrus Operator

:= assigns a value inside an expression (Python 3.8 and later). It shortens the common "compute, then test" pattern, for example matching a regular expression and using the match:

Use it where it makes the code shorter and clearer; a plain assignment on the line before is often easier to read.

Common Mistakes

  • if status == 500 or 503: is always true. Use status in (500, 503).
  • value or default when 0, "" or [] are valid values. Test is None instead.
  • A while loop without a deadline or a sleep. It can hang a deployment forever or spin a CPU at 100%.
  • Measuring timeouts with time.time(). The wall clock can jump; use time.monotonic().
  • Changing a list while looping over it. Removing items shifts the rest, so the loop skips some:

Build a new list instead (the last line is a list comprehension, covered in Part 5), or loop over a copy with for host in list(hosts):.

  • Retrying errors that cannot succeed, such as a 400 or 404, or retrying a request that is not safe to repeat.

Summary

  • if, elif and else choose a block; indentation defines the blocks.
  • and and or short-circuit and return one of their operands; x or default treats 0 and empty values as missing.
  • match compares a value against patterns and extracts parts of lists and dictionaries; put specific cases first.
  • for loops over any sequence; enumerate, zip(strict=True) and range cover counters, parallel lists and batches; for ... else runs when no break happened.
  • Wait loops need a deadline, a sleep, time.monotonic() and backoff with jitter.

Next: Functions, where these loops become reusable functions with type hints, and the retry logic becomes a decorator.

Keep Learning

Updated:

Found an issue?