Support

Handling API Errors in Python: Status Codes, Retries, Graceful Failure

By Simon O'Connor · Updated 18 June 2026 · 9 min read

A 200 status means the HTTP exchange worked. It does not mean you got the data you asked for. Handling API errors in Python comes down to checking the three separate layers where a call can fail: the transport, the HTTP status, and the response body.

This guide covers each layer with requests: why a 200 can still be a failure, how raise_for_status() turns bad statuses into catchable exceptions, how the requests exception hierarchy is organised, the 4xx-versus-5xx split that decides whether a retry can help, and how to fail gracefully. It assumes Python 3.10 or later.

Terminal
pip install requests

It returned 200, so it worked

Here is the optimistic version of an API call: ask for data and use it straight away.

Python
import requests

response = requests.get("https://api.example.com/search?q=widgets", timeout=10)
results = response.json()

print(f"Found {len(results['items'])} items")

This works until it does not. If the search found nothing, items is an empty list and the count is zero rather than an error. If your API key expired, the server may return a 401 with a JSON body explaining the problem, and response.json() parses that explanation into a dictionary with no items key, raising a confusing KeyError three lines later. Bytes came back, so the HTTP call succeeded. You did not get what you asked for, so it failed.

Transport success is not semantic success

A status code tells you whether the HTTP exchange worked. It does not tell you whether the operation behind it did what you wanted. A 200 with an empty result, or a 200 wrapping an error message in its body, is transport success and semantic failure at the same time. Keep those two questions separate and you will categorise failures correctly.

The three layers a call can fail at

Every API call passes through three layers. Each fails in its own way and surfaces the failure differently.

  • The transport layer. Did the request reach the server and come back at all? Failures here are DNS errors, refused connections, and timeouts. In requests these are raised as exceptions, so they interrupt your code immediately.
  • The HTTP layer. The server answered, but with what status? A 404, 401, or 500 is a complete, valid HTTP response. It does not raise anything on its own. You have to inspect the status code to notice it.
  • The semantic layer. The status was fine, but the body is not what you needed: an empty result, a missing field, an error object wrapped in a 200. Nothing in the HTTP machinery flags this. Only your own code, which knows what a good response looks like, can catch it.

The common mistake is handling all three with one tool. A bare try/except catches transport failures but sails past a 404. Checking the status code catches HTTP failures but says nothing about an empty body. Each layer needs its own check.

Status code families at a glance

Status codes come in ranges, and the range matters more than the exact number when you decide what to do. The first digit tells you most of it.

Range Family What it means for you
2xxSuccessThe request worked. Read the body and carry on.
3xxRedirectionThe resource moved. requests follows these for you by default, so you rarely handle them yourself.
4xxClient errorYour request was usually wrong: bad auth, missing resource, invalid input. Fix the request. Most 4xx responses should not be retried.
5xxServer errorTheir server failed. Often temporary, and often safe to retry with backoff.

The 4xx-versus-5xx split is the most useful starting point in error handling. Most 4xx responses mean your request needs to change, so repeating the same request is pointless. Most 5xx responses mean the server failed to handle a request that may have been valid, so a retry can help. The exceptions matter: 429 Too Many Requests is a rate-limit response and may include a Retry-After header, while some APIs treat 408 Request Timeout as retryable.

raise_for_status: turn bad statuses into exceptions

A bad status does not raise anything on its own. raise_for_status() checks the status code and raises an HTTPError for any 4xx or 5xx, while doing nothing for a 2xx.

Python
import requests

response = requests.get("https://api.example.com/data", timeout=10)
response.raise_for_status()  # raises HTTPError on a 4xx or 5xx
data = response.json()

This converts the HTTP layer into the same exception-based model as the transport layer, so a single try/except covers both. Without it, a 500 slips through and you discover the problem only when response.json() fails to parse an error page, with a traceback that points at the wrong line.

raise_for_status only reads the status line

It checks the status code and nothing else. An API that returns 200 with {"error": "quota exceeded"} in the body will sail straight through raise_for_status() without complaint. The signature is code that "works" but acts on an error payload as if it were data. For APIs that wrap errors in a successful status, you still have to inspect the body yourself.

The requests exception hierarchy

Once failures arrive as exceptions, you need to know what you are catching. The requests exceptions form a small family tree, and catching the right level lets you respond specifically where it helps and generally where it does not.

  • RequestException is the base class. Every error requests raises inherits from it, so catching this catches everything.
  • ConnectionError covers transport failures: DNS lookups that fail, refused connections, dropped sockets.
  • Timeout is raised when your timeout elapses, whether on connect or on read.
  • TooManyRedirects is raised when the request exceeds the configured redirect limit.
  • HTTPError is what raise_for_status() raises for a 4xx or 5xx. It carries the response, so you can inspect the status and body.
  • JSONDecodeError is raised when response.json() cannot decode the response body as JSON.

Because they share a base class, catch a specific type when you have a specific response to it, and fall back to RequestException for everything else. Order matters: Python tries except clauses top to bottom, so the specific ones go first and the general one goes last.

A robust try/except pattern

Here is a pattern that handles each failure mode at the level it deserves.

Python
import requests

try:
    response = requests.get("https://api.example.com/data", timeout=10)
    response.raise_for_status()
    data = response.json()
except requests.exceptions.Timeout:
    print("The request timed out. The server may be slow or unreachable.")
except requests.exceptions.ConnectionError:
    print("Could not reach the server. Check the network or the URL.")
except requests.exceptions.HTTPError as err:
    print(f"The server returned an error status: {err.response.status_code}")
except requests.exceptions.JSONDecodeError:
    print("The server returned a response that was not valid JSON.")
except requests.exceptions.TooManyRedirects:
    print("The request followed too many redirects. Check the URL.")
except requests.exceptions.RequestException as err:
    print(f"An unexpected request error occurred: {err}")

Each clause says something specific instead of collapsing every failure into one vague message. The final RequestException clause is a safety net for anything the earlier clauses did not name. Note what is absent: a bare except Exception, which would hide bugs in your own code, like a typo in a variable name, behind a message about the network.

Reading the error the server sent

When the server returns an error status, it usually explains why in the body, and that explanation is the fastest route to a fix. An HTTPError carries the response, so you can read both the status code and the message.

Python
import requests

try:
    response = requests.get("https://api.example.com/data", timeout=10)
    response.raise_for_status()
except requests.exceptions.HTTPError as err:
    status = err.response.status_code
    print(f"Request failed with status {status}")
    try:
        details = err.response.json()
    except requests.exceptions.JSONDecodeError:
        details = err.response.text
    print("Server said:", details)

A 401 body often says the token is missing or expired. A 422 usually lists which fields were invalid. Logging only "request failed" throws that away and turns a two-minute fix into an afternoon of guessing. The guard around err.response.json() matters because error bodies are not always JSON; sometimes they are HTML, plain text, or empty.

4xx vs 5xx: bad request vs server trouble

The status family tells you whether trying again can help. Most 4xx responses mean the request itself was wrong: a wrong endpoint, a missing or expired token, malformed input. The identical request produces the identical error, so a retry wastes time and adds load. A 5xx means the server failed to handle a request that may have been valid. That is often transient, so a retry after a short, growing delay frequently succeeds.

Do not hand-roll that retry loop. Spacing out attempts, capping them, and backing off correctly is fiddly to get right, and requests already supports it through a mounted adapter. The companion guide, Timeouts, Retries, and Backoff Done Right, retries server errors, connection failures, and documented retryable responses such as 429 while leaving ordinary 4xx mistakes alone. Error handling decides whether to retry; that guide handles the how.

Failing gracefully

Catching an error is half the job. The other half is deciding what the program does next, and "print a message and crash" is rarely the right answer for real software. Graceful failure means the rest of the program keeps working even when one call does not.

What that looks like depends on the call.

  • Fall back to a default when a missing value is survivable, such as showing cached weather when the live call fails.
  • Skip and continue when you are processing many items and one bad response should not sink the batch.
  • Surface and stop when the call is essential, replacing the traceback with a message a user can act on.

Putting it together

Here is a wrapper that ties the layers together. It returns validated JSON on success, re-raises most 4xx responses because the request needs to change, treats retryable outages as None, and rejects response bodies that are not shaped like successful data.

api_client.py
import requests


def validate_payload(data):
    """Return True only for the success shape this caller expects."""
    return isinstance(data, dict) and isinstance(data.get("items"), list)


def call_api(session, url, timeout=(3.05, 27)):
    """Fetch validated JSON from an API. Returns None on a handled outage."""
    try:
        response = session.get(url, timeout=timeout)
        response.raise_for_status()
        data = response.json()
    except requests.exceptions.HTTPError as err:
        status = err.response.status_code
        if status == 429:
            # Rate limited. Let the caller fall back or retry later.
            return None
        if 400 <= status < 500 and status != 408:
            # Our request was wrong. Retrying unchanged will not help.
            raise
        # Server failure, timeout-like status, or another retryable outage.
        return None
    except requests.exceptions.JSONDecodeError:
        # The server returned a success status, but the body was not JSON.
        raise
    except requests.exceptions.TooManyRedirects:
        # Likely a configuration or URL problem, not a temporary outage.
        raise
    except requests.exceptions.Timeout:
        # Connect or read timeout.
        return None
    except requests.exceptions.RequestException:
        # Other transport-level problem: DNS, refused connection, dropped socket.
        return None

    if not validate_payload(data):
        raise ValueError("API response did not match the expected success shape.")

    return data

The caller gets a clean contract: a returned dictionary is good data, None is a handled outage to fall back on, and a raised exception means either the request is wrong or the response body is not the success shape your code promised to handle. Pair this with a session configured for retries from the companion guide and you have a client that is robust against flaky networks and honest about real mistakes.

Frequently asked questions

Is a 200 response always a success?

No. A 200 means the HTTP exchange worked, not that the operation did what you wanted. An API can return 200 with an empty result, or wrap an error message inside a successful status. That is transport success with semantic failure. For APIs that report errors in the body, you have to inspect the payload yourself, because raise_for_status() only checks the status line.

What does raise_for_status() do?

It checks the response status code and raises a requests.exceptions.HTTPError for any 4xx or 5xx, while doing nothing for a 2xx. This converts a bad HTTP status, which otherwise passes silently, into an exception you can catch in a try/except. The raised error carries the response, so you can still read the status code and body to see what went wrong.

Should I check status codes or use try/except in Python?

Both, because they cover different layers. A try/except catches transport failures like timeouts and connection errors. Calling raise_for_status() brings bad HTTP statuses into that same try/except. And inspecting the body catches semantic failures that a 200 hides. The robust pattern combines all three rather than picking one.

Should you retry 4xx errors?

Usually no. Most 4xx responses mean the request is wrong, so sending the same request again only repeats the same failure. The important exceptions are responses the API documents as retryable, especially 429 Too Many Requests, where the server is asking you to slow down and may send a Retry-After header.


Next Step:

You can now fail at the right layer instead of crashing. In the book, Chapter 9 turns this into a reusable error categoriser with backoff and logging, inside a project that keeps growing until it deploys to AWS. Chapters 3, 14, and 15 are free to read.