Building a Python API Client That Doesn’t Fall Apart When the API Misbehaves
Build a safer Python API client with timeouts, selective retries, exponential backoff, jitter, and better handling of rate limits and temporary failures.
Join the DZone community and get the full member experience.
Join For FreeThe first version of almost every API client I write looks embarrassingly simple.
Send a request. Parse the JSON. Return the result.
Something like this:
import requests
def get_data(url):response = requests.get(url)response.raise_for_status()return response.json()
For a quick test, that is usually enough.
Then I leave it running for a while. Eventually the connection hangs, the API returns a 500, or I get a 429 because I was a little too aggressive with polling. That is usually the point where the “simple client” stops being simple.
The interesting part is not making the request. It is deciding which failures are worth retrying and which ones should fail immediately. That distinction matters more than adding a generic retry loop around everything.
The First Thing I Add Is a Timeout
I used to treat timeouts as an optional detail.
I do not anymore.
A request without an explicit timeout can wait much longer than expected when the remote service is slow or unreachable. For a script that runs once, that is annoying. For a worker or monitoring process, it can gradually turn into a much larger problem.
So even before thinking about retries, I normally start with:
response = requests.get(url,params=params,timeout=10)
Ten seconds is not a universal recommendation. It depends on what the API is doing.
But I prefer having a number I deliberately chose over letting a network call wait indefinitely.
For a lightweight market-data endpoint, ten seconds already feels generous. For a large export or a slower internal service, I might choose something else. The important part is that the timeout is intentional.
Not Every Error Should Be Retried
This was probably the mistake I made most often when I first started adding retry logic.
The naive version looks like:
for attempt in range(5):try:return make_request()except Exception:time.sleep(2)
It feels robust because the program “keeps trying.” In reality, it can make things worse. If the server returns 401 Unauthorized, retrying the same request five times will not fix the credentials.
If the endpoint returns 404, waiting two seconds and asking for the same missing resource again is usually pointless.
If the request itself is invalid, a retry just repeats the same bad request.
The failures I usually consider temporary are things such as:
- connection errors and timeouts
429 Too Many Requests- some
5xxserver errors
Everything else deserves more careful treatment. A client should not confuse persistence with resilience.
A Small Retry Function
For smaller projects, I like keeping the behavior visible rather than hiding everything inside a large abstraction.
A basic version might look like this:
import randomimport timeimport requests
RETRYABLE_STATUS_CODES = {429,500,502,503,504}
def get_json(url, params=None, max_attempts=4):for attempt in range(max_attempts):try:response = requests.get(url,params=params,timeout=10)
if response.status_code in RETRYABLE_STATUS_CODES:raise requests.HTTPError(f"Temporary HTTP error: {response.status_code}",response=response)
response.raise_for_status()return response.json()
except (requests.Timeout,requests.ConnectionError,requests.HTTPError) as exc:
if attempt == max_attempts - 1:raise
delay = (2 ** attempt) + random.uniform(0, 1)
print(f"Request failed: {exc}. "f"Retrying in {delay:.2f}s")
time.sleep(delay)
There is nothing particularly sophisticated here.
That is partly why I like it.
I can read the function six months later and immediately understand what it will retry.
Why I Add Jitter
The random.uniform(0, 1) part looks insignificant, but it solves a real problem.
Imagine several workers call the same API and all receive a temporary failure at roughly the same moment.
Without jitter, they might all retry after:
- 1 second
- 2 seconds
- 4 seconds
- 8 seconds
They stay synchronized.
So instead of reducing pressure on the service, they repeatedly hit it together. Adding a small random component spreads those retries out. For one local script, this barely matters. For multiple workers or scheduled jobs, it starts to matter quite a lot. It is a small example of something I see often in backend work: code that behaves perfectly with one process can behave very differently when twenty copies are running.
429 Needs a Little More Respect
Rate limits are also a case where simply retrying quickly is the wrong response.
If an API says “slow down,” sending the same request again immediately is not resilience. It is ignoring the server.
If the response includes a Retry-After header, I would rather respect it:
retry_after = response.headers.get("Retry-After")
if retry_after:delay = float(retry_after)else:delay = (2 ** attempt) + random.uniform(0, 1)
This also makes the client less dependent on my guess about how aggressive the rate limit is.
When there is no explicit guidance, exponential backoff is still a reasonable fallback.
Logging the Failure Is More Useful Than It Sounds
One thing I underestimated for a long time was logging.
When I was running scripts manually, print() felt good enough. The problem appears later, when somebody asks: “Why did this job miss data at 03:12?”
If all I know is “the request eventually failed,” debugging becomes guesswork.
At minimum, I want to know:
- When the request failed
- Which endpoint failed
- The HTTP status if one existed
- Which retry attempt it was
- How long the client waited
- Whether the final attempt failed permanently
For a real service, I would use Python's logging module instead of scattered print statements.
The logs do not need to be verbose. They need to answer questions later.
That is a different goal.
Retrying Writes Is More Dangerous
GET requests are usually where retry logic feels straightforward.
POST requests make me more cautious. Suppose a client submits an order or creates a resource. The server processes it successfully, but the connection drops before the client receives the response.
From the client's perspective, the request “failed.” If it blindly retries, the operation could happen twice. This is where idempotency becomes important.
If an API supports idempotency keys or client-generated request IDs, I use them for operations where duplicate execution would be a problem. Otherwise, I want the retry behavior for writes to be much more conservative than the behavior for reads. This is especially relevant in financial systems. A duplicate market-data request is annoying. A duplicate order is something else entirely.
The Same Pattern Shows Up in Trading APIs
I run into these problems a lot when looking at market-data and trading integrations. The domain makes the trade-offs easier to see because APIs are often being called continuously rather than once. A price-monitoring process may run for hours. A worker may request candles repeatedly. A trading application may depend on several remote services at the same time.
BYDFi is one platform I encounter through my work, so I mention it here as a disclosed real-world example rather than an independent recommendation.
The useful engineering lesson is not specific to one exchange. Whether the client talks to a trading platform, payment provider, cloud service, or internal API, the same questions keep appearing: What happens when the service is slow? Which errors are temporary? How often should I retry? Could retrying create a duplicate side effect? What information will I need when debugging this tomorrow? Those questions are much more important than the first successful API response.
I Usually Keep the Client Boring
There is always a temptation to turn a small HTTP wrapper into a miniature framework.
I try not to.
For most projects, I would rather have a client with obvious behavior than one with ten layers of abstraction. The version I want is usually boring: explicit timeout, a short list of retryable errors, limited attempts, backoff, jitter, useful logs, and special handling for operations that should not be duplicated.
Nothing about that is clever. That is the point.
Network failures are already unpredictable enough. I do not want the recovery logic to be unpredictable too.
Final Thoughts
Getting a 200 OK is the easiest part of building an API integration.
The real work starts when the remote service does something you did not expect. I have found that the most reliable clients are not the ones that retry the most. They are the ones that have a clear opinion about failure.
They know when to wait. They know when to try again. And, just as importantly, they know when to stop.
Opinions expressed by DZone contributors are their own.
Comments