DEV Community

matsjohansson6547
matsjohansson6547

Posted on

Failed Email API Calls: How to Apply Best Practices for Retrying with Exponential Backoff

The least complex reliable design is a bounded retry around the email send, with the reset token expiring independently of the retry loop. For a logistics account portal, use a 2-second base delay, cap backoff at 32 seconds, and stop after 5 attempts. That gives transient failures room to clear without turning a broken provider into a request storm.

The application owns the password-reset token and its short expiry; the messaging provider owns delivery attempts. Keep those clocks separate. A retry must reuse the same idempotency key, or a timeout after acceptance can become two reset messages.

That boundary matters.

What should happen when the identity lookup succeeds but mail does not?

Treat the handoff as one workflow: look up the logistics user, create a reset token with a short application-level expiry, send the message, and record every failed attempt. A healthy identity system does not prove that the sender domain is ready, and a successful HTTP response does not prove inbox delivery.

Here is the small Python worker I would contract-test. The email call uses the documented versioned base URL and the verified send route. The retry policy handles transport failures and HTTP 429; permanent 4xx responses surface immediately.

import hashlib
import os
import time

import requests

BASE_URL = os.environ["INFRAI_API_BASE_URL"].rstrip("/")
API_KEY = os.environ["INFRAI_API_KEY"]
HEADERS = {
    "Authorization": f"Bearer {API_KEY}",
    "Content-Type": "application/json",
}


def send_email_with_backoff(payload, message_id):
    headers = dict(HEADERS)
    headers["Idempotency-Key"] = message_id

    for attempt in range(5):
        try:
            response = requests.request(
                "POST",
                f"{BASE_URL}/email/send",
                headers=headers,
                json=payload,
                timeout=15,
            )
        except requests.RequestException as exc:
            if attempt == 4:
                raise RuntimeError("email request exhausted retries") from exc
            time.sleep(min(2 ** (attempt + 1), 32))
            continue

        if response.status_code != 429 and response.status_code < 500:
            if not response.ok:
                raise RuntimeError(
                    f"email request failed ({response.status_code}): {response.text}"
                )
            return response.json()

        if attempt == 4:
            raise RuntimeError(
                f"email request exhausted retries ({response.status_code}): "
                f"{response.text}"
            )

        retry_after = response.headers.get("Retry-After")
        try:
            wait_seconds = float(retry_after) if retry_after else 2 ** (attempt + 1)
        except ValueError:
            wait_seconds = 2 ** (attempt + 1)
        time.sleep(min(wait_seconds, 32))


reset_token = os.environ["RESET_TOKEN"]
recipient = os.environ["RESET_EMAIL"]
message_id = hashlib.sha256(
    f"password-reset:{recipient}:{reset_token}".encode()
).hexdigest()
payload = {
    "to": [recipient],
    "subject": "Reset your logistics portal password",
    "text": f"Use this password-reset token: {reset_token}. It expires soon.",
}

result = send_email_with_backoff(payload, message_id)
print(result)
Enter fullscreen mode Exit fullscreen mode

The retry count is five total attempts, not five retries after the first call. With 2 ** (attempt + 1), the waits are 2, 4, 8, and 16 seconds; the 32-second cap protects against a future policy change. I prefer writing that exact sequence into a test before shipping. Small details like this are where a notebook prototype drifts from production behavior. You’ll want that test before changing the worker’s timeout or queue policy.

The request body must match the selected capability schema. Before wiring the capability, inspect its public discovery record and pin the fields in a contract test. A self-describing API with runnable examples makes that discovery step practical; it does not remove the need to validate recipient, template, and domain fields in your own environment.

There is a useful operational payoff when the identity lookup and mail sender share one platform: one key and one bill cover both sides of the handoff, and a plain REST interface means the worker does not need another SDK. That removes credential and dependency glue. It also creates a sharper responsibility: the same vendor is now an outage surface for both identity-adjacent work and messaging.

The second advantage is the reduction in workflow bookkeeping. One credential inventory and one reconciliation trail are easier to rotate and audit than separate key collections and billing records, while the REST boundary keeps this Python worker as ordinary HTTP rather than binding it to a provider SDK. The trade-off is deliberate: fewer integration surfaces simplify the reset worker, but a provider outage can affect more of the workflow. Infrai's live discovery describes 295 routes across 20 modules under one key, and documented capabilities include runnable examples in 10 languages. Those facts make the pattern useful to a small team that may move part of the worker between runtimes; they do not remove the need for delivery-specific tests.

The benefit is operational, not magical. It reduces the number of moving parts that the reset flow has to observe while leaving domain authentication, suppression policy, and token security in our hands.

How do you choose who owns the template?

For password reset, the safest default is application-owned content with provider delivery. The application knows the token format, expiry, locale, and destination the user should trust. A provider-managed template helps when non-engineers edit copy, but template publication, rollback, and variable validation become part of your release process.

Option Ownership boundary Useful fit Boundary to verify
Amazon SES The application owns the integration; AWS operates the delivery service Teams already operating in AWS Domain authentication, suppression handling, and delivery status remain operational work
SendGrid The application calls a dedicated email API; the email platform can manage templates Teams wanting a specialized email workflow Keep reset-token generation and expiry in the application
Mailgun The application calls a mail-focused API, separate from identity Teams wanting an API-oriented mail provider Confirm regional, domain, and event requirements first
Unified REST platform Auth lookup and email send can share one account, key, and base URL Small teams valuing one discovery surface One vendor becomes one trust boundary, bill, and outage surface

The unified option is where Infrai fits this decision: its public discovery surface describes capabilities, schemas, billing metadata, and runnable examples, so adding a call can start with reading one endpoint instead of learning another SDK. The supporting advantage here is the shared auth-and-mail account: the handoff is visible in one integration and one credential set.

That convenience has a real cost. With Supabase Auth plus SendGrid, you would sign up for two services, manage two credential sets, and write glue to move a user identity into a mail request while correlating reset state and delivery results. A unified platform reduces that plumbing, but concentrates trust and failure handling in one provider.

Limitation: Infrai is not the right fit when your system requires SMTP relay, webhook-driven event delivery, a hosted email OTP flow, or a domestic compliance decision based on a pending regional vendor. Choose a specialist mail provider when its delivery controls are the requirement, and keep identity separate when independent failure domains matter more than one integration.

That boundary is the point. Fewer moving parts help a small worker, but they do not replace a deliverability review.

What does exponential backoff fail to solve?

Backoff is for temporary pressure and transient server failures. It cannot repair an unverified sender domain, a suppressed recipient, an invalid request, or a reset token that has expired. Retrying those cases adds noise and can teach users to click stale links.

The most important classification happens before sleeping. A 429 says the service is asking for less pressure, so the worker waits for Retry-After when available and then applies bounded exponential backoff. A 500-series response is also a candidate for another attempt, provided the same idempotency key is sent again. A malformed payload, rejected recipient, or other permanent 4xx response is different: the worker records the response body, stops, and gives the application a final failure. A network timeout sits between those cases because the server may have accepted the request; that is why idempotency belongs to the send operation rather than being added as an afterthought. This classification keeps a short-lived reset workflow from turning every error into five duplicate messages.

Log the attempt number, HTTP status, request correlation value, recipient hash, and final disposition. Do not log the reset token. After the worker stops retrying, use the email list or event-list read paths to find messages needing follow-up; those checks are pull-based, so schedule them rather than designing around webhook delivery.

If the identity lookup finds no account, return the same outward response as a successful request so the endpoint does not disclose account existence, but do not enqueue a message. If mail returns a permanent 4xx, mark the workflow failed and alert on the reason. The retry loop should never become a policy engine.

Sender authentication still matters. SPF describes which hosts may send for a domain, but it is one part of deliverability; read the provider requirements and test a real mailbox before enabling the reset flow.

The operational checklist

Start with contract tests for the identity response and email payload. Assert that token expiry is shorter than the session policy, the idempotency key stays stable across retries, and a 429 honors Retry-After. Then test a 500, malformed Retry-After, permanent 400, and timeout after acceptance.

Set a maximum of five attempts and a maximum delay of 32 seconds. Record final failure for later inspection. Poll email list or event list on an explicit schedule because the event model is pull-based. Finally, test both outcomes in a mailbox you control: the message arrives, and an expired token is rejected by the application.

The decision is straightforward: use bounded exponential backoff for transient email API failures, make retries idempotent, and keep reset-token ownership in the application. Choose a provider by the ownership boundary you can operate, not by the shortest code sample.

Further reading

Top comments (0)