Engineering
Safe retries for paid APIs: avoid double charges and lost work
Design retries for metered APIs: what is safe to retry, what may already be charged, backoff with jitter, idempotent bookkeeping, and dead-letter queues.

Retry logic is usually written once, copied everywhere, and forgotten. For free APIs that is mostly harmless, but for metered APIs a careless retry loop can quietly multiply your bill or hammer a service that is already struggling. This guide explains how to decide what is safe to retry against a paid API like Finowl, and how to build retries that recover from real failures without paying twice.
Why retries are different when calls cost money
With a free, read-only API, the worst case of an extra retry is wasted time. With a metered API, every request that reaches the lookup stage can cost a credit. The core question changes from "will this succeed if I try again?" to "did the first attempt already run?"
Every failed request falls into one of three buckets:
- Rejected before running. The service refused the request up front. Nothing ran, nothing was charged, and retrying is safe once the cause is addressed.
- Ran and failed in a confirmed way. The lookup was attempted and the service knows it was not billed.
- Outcome unknown. The request may have run. You cannot tell from the client side whether it was charged.
Good retry design is mostly about sorting errors into these buckets correctly.
What is safe to retry
429 rate_limited with Retry-After
Finowl allows 60 requests per minute per account. When you exceed that, you get 429 with this body:
{ "error": { "code": "rate_limited" } }The response also carries a Retry-After header with the number of seconds to wait. The request was rejected before running, so it is safe to resend the exact same request after that delay. Honor the header rather than guessing; retrying sooner just earns another 429. Handling rate limits and 429s covers this case in detail, including how to avoid hitting the limit in the first place.
Network errors before the request was sent
If the connection fails before any bytes of the request leave your machine, such as a DNS failure or a refused connection, the server never saw the request. These are safe to retry with backoff.
The catch is that many HTTP clients lump these together with timeouts that happen after sending. A read timeout means the request may have arrived and run. Check which exception your client raises, and treat anything after the request was sent as uncertain.
Failures confirmed as not billed
Finowl does not charge for failed lookups when the underlying provider confirms they were not billed. In batch responses this shows up directly as charged_credits: 0 on an item with an error. Those items are safe to include in a later batch.
What not to retry blindly
400-level client errors
invalid_input (400), unauthorized (401), not_found (404), method_not_allowed (405), payload_too_large (413), and json_required (415) are rejected before running and are free. They are also deterministic: the same request will fail the same way. Fix the request or the configuration instead of retrying.
429 quota_exceeded
A 429 with quota_exceeded means the account does not have enough credits. Waiting will not help unless credits are added. Stop the job, alert someone, and resume after a top-up. Auto top-ups with a saved card can prevent this in production, and the credits documentation explains how balances work.
504 provider_response_unconfirmed and other 5xx
This is the case that matters most for your bill. A 504 with provider_response_unconfirmed means Finowl sent the lookup but did not get a confirmed response back. The lookup may have run, and uncertain failures like timeouts are charged. The same caution applies more broadly: for any 5xx, the lookup may have run and may be charged.
A blind retry here can mean paying twice for one answer. That does not mean you can never retry; it means the decision should be deliberate. For a single interactive lookup, one retry is often acceptable. For a bulk job, it is usually better to set the item aside and decide in a review step.
| Response | Ran? | Safe to auto-retry? |
|---|---|---|
429 rate_limited |
No | Yes, after Retry-After |
429 quota_exceeded |
No | No, add credits first |
400-415 client errors |
No | No, fix the request |
| Network error before send | No | Yes, with backoff |
Batch item, charged_credits: 0 |
Not billed | Yes, in a later batch |
5xx, 504, read timeouts |
Maybe | No, review or retry once deliberately |
The retries documentation and the errors reference list every code.
Exponential backoff with jitter
When a retry is safe but no Retry-After header tells you how long to wait, use exponential backoff with jitter. Double the wait after each attempt, cap it, and randomize it so that many clients do not retry in lockstep.
"Full jitter" is a simple and effective variant: pick a random delay between zero and the current exponential ceiling.
import os
import random
import time
import requests
API_URL = "https://api.finowl.co/v1/gender/name"
HEADERS = {
"Authorization": f"Bearer {os.environ['FINOWL_API_KEY']}",
"Content-Type": "application/json",
}
BASE = 0.5 # seconds
CAP = 30.0 # seconds
MAX_ATTEMPTS = 5
class NeedsReview(Exception):
pass
def lookup(name, country=None):
body = {"name": name}
if country:
body["country"] = country
for attempt in range(MAX_ATTEMPTS):
try:
resp = requests.post(API_URL, headers=HEADERS, json=body, timeout=(3.05, 20))
except requests.exceptions.ConnectionError:
# Could not connect: the request was not sent.
time.sleep(random.uniform(0, min(CAP, BASE * 2 ** attempt)))
continue
except requests.exceptions.Timeout:
raise NeedsReview(f"timeout after send for {name!r}")
if resp.status_code == 200:
return resp.json()["data"]
code = resp.json().get("error", {}).get("code")
if resp.status_code == 429 and code == "rate_limited":
time.sleep(int(resp.headers.get("Retry-After", "1")))
continue
if resp.status_code >= 500:
raise NeedsReview(f"{resp.status_code} {code} for {name!r}")
raise RuntimeError(f"{resp.status_code} {code}") # 4xx: fix, do not retry
raise NeedsReview(f"gave up after {MAX_ATTEMPTS} attempts for {name!r}")
print(lookup("Jean", "FR"))Note that requests raises ConnectTimeout as both a ConnectionError and a Timeout, and it is caught by the first branch here, which is correct: a connect timeout means the request never went out. A read timeout falls to the second branch. One caveat: ConnectionError also covers a connection dropped mid-response, so if you need to be strict, inspect the underlying exception and treat anything other than a failed connect as uncertain.
Always cap the number of attempts. A retry loop without a limit turns an outage into a runaway job.
Thinking in idempotent operations
An operation is idempotent when doing it twice has the same effect as doing it once. A lookup is naturally idempotent in its answer, but not in its cost: two identical lookups return the same result and charge twice. So the idempotency has to live on your side.
Record intent before you call
Before sending a request, write a record that says "lookup for item X is in flight." After the response, mark it done and store the result. If your process crashes, you can tell the difference between items that were never sent and items whose outcome is unknown. Only the first group is safe to resend automatically.
Cache results by input
If your pipeline might ask for the same name and country pair more than once, cache the result keyed by the normalized input. A cache hit avoids a paid call entirely and makes reprocessing a dataset nearly free. This matters especially for unknown results, which are successful and charged; repeating them will not change the answer.
Never resend whole batches
For the batch endpoint, the unit of retry is the item, not the request. A 200 response can contain item-level errors alongside successes, and the successes were charged. Pull out only the failed items with charged_credits: 0 and send them in a new batch. Batch enriching 100,000 contacts shows a full resumable implementation.
Dead-letter queues for uncertain failures
Anything that falls into the "outcome unknown" bucket, or that exhausts its retry budget, should go to a dead-letter queue instead of being dropped or retried forever. A dead-letter queue can be as simple as a database table or a JSONL file with:
- the original input and your internal id
- the HTTP status and error code
- the number of attempts and the timestamp of the last one
- whether a charge may have occurred
Review the queue periodically. Often the right move is to replay it once the upstream issue has passed, accepting the possibility of a duplicate charge for a small number of items. Sometimes the right move is to leave a record as unenriched. Either way, the decision is visible and bounded, rather than hidden inside a loop.
Alert on the size of the queue rather than on individual entries. A handful of uncertain failures in a large job is normal. A sudden spike usually means an upstream outage, and the right response is to pause the job rather than keep feeding it.
Frequently asked questions
Is it safe to retry a 429 from Finowl?
It depends on the code. rate_limited is safe to retry after waiting for the Retry-After seconds. quota_exceeded means you are out of credits, so retries will keep failing until you add more.
Will I be charged for a 504 provider_response_unconfirmed?
Possibly. The lookup may have run, and uncertain failures such as timeouts are charged. Retry deliberately rather than automatically, or send the item to a review queue.
Are 400 errors charged?
No. Requests rejected before running, such as invalid_input, are free. They are also deterministic, so fix the request instead of retrying it.
Why add jitter to exponential backoff?
Without jitter, many clients that failed at the same moment retry at the same moment, causing another spike. Randomizing the delay spreads retries out and helps the service recover.
What should go into a dead-letter queue?
Requests with an uncertain outcome, such as 5xx responses and read timeouts, and anything that used up its retry budget. Store the input, error, attempt count, and whether a charge may have occurred, then review it periodically.


