Endpoint 51 Support

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

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

A request that comes back with a 200 status feels like success. The call returned, there was no exception, the program kept running. So it is tempting to read the response and move on. But "the request did not crash" and "the request did what you wanted" are two different things, and the gap between them is where most API bugs live.

Handling API errors well is not about wrapping everything in a try/except and hoping. It is about knowing the three distinct layers where a call can fail, recognising which layer a given failure belongs to, and responding at the right one. Get that mental model straight and the code almost writes itself.

This guide builds that model with requests. You will see why a 200 can still be a failure, how to turn bad HTTP statuses into exceptions you can catch, how the requests exception hierarchy is organised, and how to fail gracefully instead of dumping a stack trace on a user. It assumes Python 3.10 or later and a working pip install requests.

It returned 200, so it worked

Here is the optimistic version of an API call. It asks for some data and uses 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 right up until it does not. If the search found nothing, items might be an empty list, and the count is zero rather than an error. If your API key expired, the server may have returned a 401 with a JSON body explaining the problem, and response.json() happily parses that explanation into a dictionary that has no items key at all, raising a confusing KeyError three lines later. The HTTP call succeeded in the sense that bytes came back. It failed in the sense that you did not get what you asked for.

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, and each one fails in its own way and surfaces the failure differently. Naming them is half the battle.

  • 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 mistake most beginners make is trying to handle all three with one tool. A bare try/except catches the transport failures but sails straight past a 404. Checking the status code catches the HTTP failures but says nothing about an empty body. Each layer needs its own check, and the rest of this guide takes them in turn.

Status code families at a glance

HTTP status codes come in ranges, and the range matters more than the exact number when you are deciding what to do. The first digit tells you almost everything.

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 wrong: bad auth, missing resource, invalid input. Fix the request. Retrying changes nothing.
5xxServer errorTheir server failed. Usually temporary, and usually safe to retry with backoff.

That 4xx versus 5xx split is the single most useful distinction in error handling. A 4xx is your problem and a retry is pointless. A 5xx is their problem and a retry often succeeds. We come back to that split when we connect this to retries.

raise_for_status: turn bad statuses into exceptions

Because a bad status does not raise anything on its own, requests gives you a method that does. Calling raise_for_status() checks the status code and raises an HTTPError for any 4xx or 5xx, while doing nothing at all 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 one line converts the HTTP layer into the same exception-based model as the transport layer, which means a single try/except can now cover both. Without it, a 500 would slip through and you would only discover the problem when response.json() failed 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 are organised as 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.
  • HTTPError is what raise_for_status() raises for a 4xx or 5xx. It carries the response, so you can inspect the status and body.

Because they share a base class, you can catch a specific type when you have a specific response to it, and fall back to RequestException to mop up 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

Putting the hierarchy to work, 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.RequestException as err:
    print(f"An unexpected request error occurred: {err}")

Each clause says something useful and specific, instead of collapsing every failure into one vague message. The final RequestException clause is a safety net: if requests ever raises something the earlier clauses did not name, it still gets handled rather than crashing the program. Notice what is not here: a bare except Exception. Catching everything hides bugs in your own code, such as 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}")
    print("Server said:", err.response.text)

A 401 body often tells you the token is missing or expired. A 422 usually lists exactly which fields were invalid. Throwing that detail away and logging only "request failed" turns a two-minute fix into an afternoon of guessing. If you know the error body is JSON, err.response.json() gives you the structured version to pull a specific message out of.

4xx vs 5xx: your bug vs their problem

This is where error handling meets retries. The status family tells you whether trying again could possibly help.

A 4xx means the request itself was wrong. A wrong endpoint, a missing or expired token, malformed input. Sending the identical request again will produce the identical error, so a retry just wastes time and adds load. The fix is in your code, not in patience. A 5xx, on the other hand, means the server failed to handle a request that may have been perfectly valid. That is often transient, so a retry after a short, growing delay frequently succeeds.

You should not hand-roll that retry loop, though. Spacing out attempts, capping them, and backing off correctly is fiddly to get right, and requests already supports it through a mounted adapter. Our companion guide, Timeouts, Retries, and Backoff Done Right, covers exactly how to retry 5xx and connection failures automatically while leaving 4xx alone. Error handling decides whether to retry; that guide handles the how.

Failing gracefully

Catching an error is only half the job. The other half is deciding what your program should do 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 graceful looks like depends on the call. Sometimes you can fall back to a cached or default value. Sometimes you skip the failed item and carry on with the rest. Sometimes you surface a clear, human message instead of a stack trace, and stop. The common thread is that the failure is a planned branch in your code, not an unhandled crash.

  • 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 small wrapper that ties the layers together. It returns parsed JSON on success, re-raises a 4xx because that is a bug worth fixing loudly, and returns None for the failures that a caller can reasonably treat as a temporary outage.

api_client.py
import requests


def call_api(session, url, timeout=(3.05, 27)):
    """Fetch JSON from an API. Returns None on a handled, temporary failure."""
    try:
        response = session.get(url, timeout=timeout)
        response.raise_for_status()
        return response.json()
    except requests.exceptions.HTTPError as err:
        status = err.response.status_code
        if 400 <= status < 500:
            # Our request was wrong. Retrying will not help, so fail loudly.
            raise
        # Their server failed. Treat as a temporary outage the caller can handle.
        return None
    except requests.exceptions.RequestException:
        # Transport-level problem: timeout, DNS, refused connection.
        return None

The caller then has a clean contract. A returned dictionary is good data, None is a handled outage to fall back on, and a raised exception is a genuine bug in how the request was built. Pair this with a session configured for retries from the companion guide and you have a client that is both 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.

Mastering APIs with Python

Knowing which layer a failure belongs to, and responding at the right one, is the difference between code that limps along and code you can trust in production. In the full book, you build real API clients and make them robust end to end: error handling, retries, logging, and tests, applied across six portfolio projects covering Flask, OAuth, SQLite, Postgres, Docker, CI/CD, and AWS.

Get the book for €35

Chapter 3 is free to read.