Skip to content
Last updated

Tutorial: Retry with exponential backoff

Why you need retries

Network timeouts, 5xx server errors, and rate limits (429) are normal in distributed systems. Without retries, a single transient failure can cause a lost payment or a broken checkout flow.

The algorithm

delay = min(base × 2^attempt + random_jitter, max_delay)
ParameterRecommended valuePurpose
base1 secondStarting delay
max_delay30 secondsCap so you don't wait too long
max_retries3Limit total attempts
jitter0–1 second randomPrevent thundering herd

Visual flow

image

When to retry

ResponseRetry?Reason
200 with response_status: approvedNoSuccess
200 with response_status: declinedNoIssuer refused — ask for different card
200 with response_status: errorYesGateway/processor issue
400, 401, 404, 422NoClient error — fix the request
429YesRate limited
500, 502, 503, 504YesServer error — transient
Timeout / connection errorYesNetwork issue

Complete implementation

import time, random, requests, os, logging

logger = logging.getLogger("paradise")

API_KEY = os.environ["PARADISE_API_KEY"]
BASE = os.environ.get("PARADISE_BASE_URL", "https://api.dev.paradisegateway.net")

def send_with_retry(
    method: str,
    path: str,
    payload: dict | None = None,
    max_retries: int = 3,
    base_delay: float = 1.0,
    max_delay: float = 30.0,
) -> dict:
    url = f"{BASE}{path}"
    headers = {"Content-Type": "application/json", "APIKEY": API_KEY}

    for attempt in range(max_retries + 1):
        try:
            resp = requests.request(
                method, url, headers=headers, json=payload, timeout=30
            )
            body = resp.json()

            if resp.status_code == 200 and body.get("response_status") != "error":
                return body

            if 400 <= resp.status_code < 500 and resp.status_code != 429:
                logger.error(
                    "Non-retryable error %s: %s (correlation_id=%s)",
                    resp.status_code, body, body.get("correlation_id"),
                )
                return body

        except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e:
            body = {"error": str(e)}

        if attempt == max_retries:
            logger.error("Max retries exhausted for %s %s", method, path)
            return body

        delay = min(base_delay * (2 ** attempt) + random.uniform(0, 1), max_delay)
        logger.warning(
            "Attempt %d/%d failed, retrying in %.1fs (correlation_id=%s)",
            attempt + 1, max_retries, delay, body.get("correlation_id", "n/a"),
        )
        time.sleep(delay)

    return body

Usage

result = send_with_retry("POST", "/v1/transactions", {
    "type": "sale",
    "amount": 1999,
    "payment_method": {
        "type": "card",
        "card": {
            "pan": "4111111111111111",
            "expiry_month": "12",
            "expiry_year": "2027",
            "cvv": "123",
            "cardholder_name": "John Doe",
        },
    },
})

Testing retries in sandbox

Simulate transient failures by sending requests with intentionally malformed auth to trigger 401, or use test card numbers that produce declines to verify your non-retry path works correctly.

Anti-patterns

MistakeWhy it's badFix
Retry on 400The request is broken — retrying won't helpFix the payload
Retry on declinedThe issuer said no — retrying charges the customer's card repeatedlyAsk for a different card
No jitterAll your servers retry at the same timeAdd random jitter
No max retriesInfinite loop during an outageCap at 3 retries
No idempotency keyRetries may create duplicate transactionsAlways send Idempotency-Key

Next steps