Python requests Error Handling: Timeouts, Retries, and 429s

🌐 한국어

Calling an API from Python starts as one line.

data = requests.get(url).json()

It works fine locally. Put it on a server for a few hours and it will break — hanging forever waiting for a response that never comes, or throwing from .json() because a 429 came back, or returning 401 because the token expired.

This article is about turning that one line into code that doesn't wake you at 3 a.m.

1. Timeouts — the Most Common and Most Damaging Omission

requests has no default timeout. If the server never responds, your program waits indefinitely. Check back hours later and it's still sitting there. Nothing in the logs. It just quietly stops.

# Bad — waits forever if the server neither dies nor answers

res = requests.get(url)

# Good — (connect timeout, read timeout)

res = requests.get(url, timeout=(3, 10))

The tuple means 3 seconds to connect, 10 seconds to receive the body. Giving up quickly on the connection while allowing more room for the data transfer is usually the right shape. If you can't decide on values, even a bare timeout=10 beats having none.

2. Distinguish Between the Failures

Wrap every failure in except Exception and you lose the ability to tell what should be retried from what shouldn't. The requests exceptions break down roughly like this:

  • Timeout — timed out. Worth retrying
  • ConnectionError — connection failure, DNS error, network drop. Worth retrying
  • HTTPError — a 4xx/5xx response. Depends on the status code

By status code:

  • 5xx: server-side problem. Retry.
  • 429: you called too often. Wait, then retry.
  • 401 / 403: authentication problem. A plain retry fails identically. You need to refresh the token and try again.
  • 400 / 422: your request is malformed. Retrying is pointless. Send it 100 times and it's wrong 100 times. Log it and give up immediately.

3. Retrying with Exponential Backoff

If every retry waits a flat 0.5 seconds, you're piling requests onto a server that's already struggling. The standard approach is waiting longer with each failure — exponential backoff.

import time, random, requests

RETRIABLE_STATUS = {429, 500, 502, 503, 504}

def request_with_retry(method, url, max_retries=3, **kwargs):

    kwargs.setdefault("timeout", (3, 10))

    last_err = None

    for attempt in range(max_retries):

        try:

            res = requests.request(method, url, **kwargs)

            # Return immediately on failures not worth retrying

            if res.status_code < 400:

                return res

            if res.status_code not in RETRIABLE_STATUS:

                res.raise_for_status()

            last_err = f"HTTP {res.status_code}"

            wait = backoff_seconds(attempt, res)

        except (requests.Timeout, requests.ConnectionError) as e:

            last_err = str(e)

            wait = backoff_seconds(attempt, None)

        if attempt < max_retries - 1:

            time.sleep(wait)

    raise RuntimeError(f"failed after {max_retries} retries: {last_err}")

def backoff_seconds(attempt, res):

    # 1s -> 2s -> 4s, plus a random jitter

    base = 2 ** attempt

    return base + random.uniform(0, 0.5)

There's a reason for the jitter — the random value added on top. When several bots fail at the same moment, they all retry at exactly the same instant, and the server gets hit by the same thundering herd again. Mixing in a random 0–0.5 seconds is enough to break that synchronization.

4. On a 429, Read Retry-After

When you get a 429 (Too Many Requests), many servers tell you when to come back. It's in the Retry-After response header. Ignore it and retry on your own schedule and you may extend the block.

def backoff_seconds(attempt, res):

    if res is not None and res.status_code == 429:

        retry_after = res.headers.get("Retry-After")

        if retry_after:

            try:

                return float(retry_after) + 0.1   # wait as long as the server asked

            except ValueError:

                pass                              # date format -> fall through to default

        return max(5.0, 2 ** attempt)             # no header -> be generous

    return 2 ** attempt + random.uniform(0, 0.5)

The better answer is not getting 429s at all. Enforce a minimum interval between calls on the client side.

class RateLimiter:

    """Guarantees a minimum interval between calls"""

    def __init__(self, min_interval=0.1):

        self.min_interval = min_interval

        self.last_call = 0.0

    def wait(self):

        elapsed = time.time() - self.last_call

        if elapsed < self.min_interval:

            time.sleep(self.min_interval - elapsed)

        self.last_call = time.time()

5. A 401 Calls for a Token Refresh, Not a Retry

This is the failure you'll meet most often with token-based APIs. Tokens generally have an expiry, and past it you get a 401. What's needed then isn't waiting, it's reissuing.

def call_api(path, **kwargs):

    headers = kwargs.pop("headers", {})

    headers["Authorization"] = get_token()

    res = request_with_retry("GET", BASE + path, headers=headers, **kwargs)

    if res.status_code == 401:

        refresh_token()                              # get a fresh token

        headers["Authorization"] = get_token()

        res = request_with_retry("GET", BASE + path, headers=headers, **kwargs)

    res.raise_for_status()

    return res.json()

Attempt the reissue exactly once. If 401s keep coming and you refresh indefinitely, a genuinely bad key puts you in an infinite loop.

6. .json() Fails Too

The last trap. Even with a 200 status code, the body may not be JSON. The classic case is a proxy in front of the server returning an HTML error page.

try:

    data = res.json()

except ValueError:

    logging.error("JSON parse failed: %s", res.text[:200])   # keep only the head

    raise

Logging the whole of res.text will balloon your log files the moment a response is large. The first 200 characters are plenty to identify the cause.

Checklist

Confirm these before committing API-calling code and you'll prevent most incidents.

  • Does every request have a timeout?
  • Are you retrying 4xx errors that retrying can't fix?
  • Does the wait grow with each failure, and is there jitter?
  • Do you read Retry-After on a 429?
  • Do you refresh the token on a 401 — and only once?
  • When everything fails, does the log say what failed and why?

That last item matters more than it looks. Build beautiful retry logic and then swallow it with except: pass, and your bot doesn't die — it just stops doing anything. Failures have to make noise.

This article was written for educational and informational purposes. The code is illustrative; when applying it to a real service, check the target API's documentation and usage policies.

댓글

이 블로그의 인기 게시물

한국투자증권 KIS API로 실시간 시세 받기 (WebSocket 실전)

파이썬으로 업비트 API 연동하기 — 시세 조회부터 주문까지 기초

Go로 자동매매 신호봇 프레임워크 설계하기