Skip to content

Exponential backoff with jitter for calls that fail intermittently — decorator and one-shot forms, idempotency rules, and the failure modes retrying makes worse.

Some failures are permanent. A 404 will still be a 404 in five seconds. Some failures are not: a connection reset, a DNS blip, a database failover, a rate limit that expires, a service restarting behind a load balancer. Those succeed if you ask again.

Retrying the second kind converts a user-visible error into a slightly slower success. Retrying the first kind wastes time and, if the operation had side effects, causes damage. This module gives you the mechanism; deciding which failures qualify is on you, and it is the part that matters.

from sillo.helpers import retry

Stdlib-only.

Exponential backoff, and why the delay grows

Section titled “Exponential backoff, and why the delay grows”

Retrying immediately is usually worse than not retrying. A service that just failed is often overloaded, and hammering it with instant retries from every client is how a brief blip becomes a sustained outage.

Backoff means waiting longer after each failure. With the defaults, delays double:

attempt 1 fails -> wait 2s
attempt 2 fails -> wait 4s
attempt 3 fails -> wait 8s
attempt 4 fails -> wait 16s
attempt 5 fails -> wait 32s
attempt 6 fails -> wait 60s (capped by max_delay)

The cap matters. Without it, doubling reaches hours, and a background job sleeps past any deadline you cared about.

Jitter randomizes each delay. It is on by default and you should leave it on. Without jitter, a thousand clients that fail at the same instant all retry at exactly the same instant, then again two seconds later, and again four seconds later. The service gets synchronized waves of load precisely when it is trying to recover. Randomizing spreads them out:

_compute_delay(3, base=1.0, factor=2.0, cap=10.0, jitter=True)
# 4.98, 6.98, 2.49, 7.83, 0.85 ... different every call, never above the cap

@retry(max_attempts=3, base_delay=1.0, max_delay=60.0, backoff_factor=2.0, jitter=True, retryable_exceptions=Exception)

Section titled “@retry(max_attempts=3, base_delay=1.0, max_delay=60.0, backoff_factor=2.0, jitter=True, retryable_exceptions=Exception)”

Wraps a function so every call retries on failure. Works on both sync and async functions — it inspects the target at decoration time and returns the matching wrapper:

from sillo.helpers.retry import retry
@retry(max_attempts=5, base_delay=1.0)
async def fetch_rates():
return await http_client.get("https://api.example.com/rates")
@retry(max_attempts=3)
def read_config():
return open("/mnt/shared/config.json").read()

A call that eventually succeeds returns normally, and the caller cannot tell it took three attempts. A call that exhausts every attempt raises RetryError:

from sillo.helpers.retry import RetryError
try:
rates = await fetch_rates()
except RetryError as exc:
logger.error("rate service unreachable: %s", exc)
# 'Retry failed after 5 attempts'
rates = cached_rates()

retryable_exceptions narrows which failures trigger a retry. Everything else propagates immediately:

@retry(max_attempts=3, retryable_exceptions=(ConnectionError, TimeoutError))
async def call_api():
...

The default is Exception, which retries everything.

When you cannot decorate the function — it belongs to a library, or the retry policy varies per call site — use the functional forms.

from sillo.helpers.retry import sync_retry
result = sync_retry(
requests.get,
"https://api.example.com",
max_attempts=3,
base_delay=0.5,
retryable_exceptions=(ConnectionError,),
)
from sillo.helpers.retry import async_retry
result = await async_retry(
http_client.get,
"https://api.example.com",
max_attempts=3,
base_delay=0.5,
)

Positional and keyword arguments pass straight through to the wrapped callable. The retry-control keywords (max_attempts, base_delay, max_delay, backoff_factor, jitter, retryable_exceptions) are consumed by the retry wrapper.

Idempotency: the rule that decides everything

Section titled “Idempotency: the rule that decides everything”

Retrying is only safe when performing the operation twice has the same effect as performing it once. That property is called idempotency, and it is not a detail — it is the whole question.

The dangerous case is a request that succeeded on the server and failed on the way back. The server charged the card, then the connection dropped before the response arrived. Your client sees a ConnectionError, retries, and charges the card again.

Not safe to retry
@retry(max_attempts=3)
async def charge_customer(customer_id: str, amount: int):
return await payments.charge(customer_id, amount) # may charge three times
Safe to retry, because the key deduplicates server-side
from sillo.helpers.strings import random_token
async def charge_customer(customer_id: str, amount: int):
key = random_token(16) # generated ONCE, outside the retry
@retry(max_attempts=3, retryable_exceptions=(ConnectionError, TimeoutError))
async def attempt():
return await payments.charge(customer_id, amount, idempotency_key=key)
return await attempt()

The key must be created outside the retried function. Generating it inside means a fresh key per attempt, which is exactly the duplicate-charge bug with extra steps.

A rough guide:

OperationRetry?
GET / read queryYes
PUT with a full resource bodyYes, it is idempotent by definition
DELETEUsually — the second one 404s, which you can treat as success
POST that creates somethingOnly with an idempotency key
Payment, email send, SMSOnly with an idempotency key
Database write inside a transactionRetry the whole transaction, never one statement

The most common mistake with this module is putting it somewhere it makes the user experience worse.

Retries add load exactly when a system is least able to take it. Three specific failure modes to know about.

Retry amplification. With three services chained and three attempts each, one user request becomes up to 27 calls at the bottom of the stack. A partial failure at the leaf multiplies into a load spike that guarantees a total one. Retry at one layer, ideally the outermost, and let inner layers fail fast.

The synchronized herd. Covered above, and the reason jitter defaults to on. Never turn it off in production; the only reason to disable it is deterministic tests.

Retrying past the point of usefulness. A service that has been down for a minute is not coming back within your backoff window. Continuing to retry keeps your workers occupied and adds load to a system already in trouble. A circuit breaker — fail fast for a cooldown period after N consecutive failures, then probe with a single request — is the standard complement to retries, and this module does not provide one. For a dependency you call constantly, add one.

A retry budget helps too. Cap retries as a fraction of total requests (say 10%), so a broad failure cannot triple your outbound traffic no matter how many individual calls want to retry.

A silent retry is a hidden latency problem. An endpoint that “works” but succeeds on the third attempt has a p99 measured in seconds, and nothing in your metrics says why.

Log when retries happen, not just when they are exhausted:

Making retries visible
import logging
from sillo.helpers.retry import async_retry, RetryError
logger = logging.getLogger(__name__)
async def fetch_with_logging(url: str):
attempts = 0
async def attempt():
nonlocal attempts
attempts += 1
return await http_client.get(url)
try:
result = await async_retry(attempt, max_attempts=3, base_delay=0.2)
if attempts > 1:
logger.warning("succeeded on attempt %d for %s", attempts, url)
return result
except RetryError:
logger.error("exhausted %d attempts for %s", attempts, url)
raise

Emit a counter for retries and a separate one for exhaustions. Retries climbing while exhaustions stay flat is an early warning; exhaustions climbing is an incident.

Scenariomax_attemptsbase_delaymax_delayRetryable
Inside a request handler20.10.5Timeouts only
Background job, external API51.060Connection, timeout, 5xx
Queue worker, database30.55Operational errors only
Startup dependency check101.030Connection errors
Webhook delivery52.0300Connection, timeout, 5xx

Startup checks are the one place a long total budget is right. Waiting 30 seconds for a database to accept connections during a rolling deploy is better than crash-looping the container.

Do not retry with the default retryable_exceptions. It retries your own bugs.

Do not retry non-idempotent operations without an idempotency key. Double charges, duplicate emails.

Do not generate the idempotency key inside the retried function. A new key per attempt defeats it.

Do not use long backoff in a request handler. The user is waiting and your worker is blocked.

Do not disable jitter in production. Synchronized retries are how a blip becomes an outage.

Do not retry at every layer. Amplification. Pick one layer.

Do not retry 4xx responses. The request is wrong; repeating it verbatim cannot help.

Do not retry silently. Log and count them, or your latency problem is invisible.

Do not assume except ConnectionError: still fires. After @retry, it is RetryError.

Do not retry without a timeout on the underlying call. Retrying an operation that hangs forever means hanging forever, three times.

The retry wrappers themselves cost nothing measurable — a loop, an exception check, and a delay computation.

The cost is the waiting. With the defaults, five attempts spend up to 30 seconds sleeping. In async code that sleep is awaited and the event loop serves other requests, so the cost is latency for that one caller. In sync code the thread is blocked, and a thread pool full of retrying threads serves nobody.

That asymmetry is worth remembering: async_retry and the async decorator are cheap under concurrency. sync_retry is not, and its cost scales with how many threads you have.

NameSignatureNotes
retry(max_attempts=3, base_delay=1.0, max_delay=60.0, backoff_factor=2.0, jitter=True, retryable_exceptions=Exception)Decorator; handles sync and async
async_retry(coro, *args, **retry_kwargs, **kwargs)One-shot for coroutine functions
sync_retry(func, *args, **retry_kwargs, **kwargs)One-shot for sync callables
RetryErrorExceptionRaised when attempts are exhausted
ParameterDefaultEffect
max_attempts3Total attempts, including the first
base_delay1.0Seconds before the first retry
max_delay60.0Ceiling on any single delay
backoff_factor2.0Multiplier per attempt
jitterTrueRandomizes delay. Leave on.
retryable_exceptionsExceptionWhich failures retry. Narrow this.