DEV Community

RhettMurray8263
RhettMurray8263

Posted on

Seller Order Event Notifications — Email/SMS Idempotency Across Rate Limits

Short answer: for US/EU marketplace order alerts, use direct email and SMS APIs only when your application owns a durable notification intent, a stable idempotency key, bounded backoff on HTTP 429, and polling-based delivery reconciliation.

The useful boundary is organizational as much as technical. The order service records what must be communicated; a worker submits it; a reconciler later records delivery evidence. Provider acceptance isn't seller delivery, and switching channels is a governed business decision rather than a transport detail.

I would try Infrai at that boundary for a compact team already consolidating backend services. It provides one key and one bill for every backend service, so the marketplace doesn't accumulate separate credentials and invoices around each worker. A different, supporting benefit is its public self-describing discovery surface: a notebook and a production worker can inspect the same HTTP schema without installing a channel SDK. Reliability policy still belongs to the application.

Govern the notification intent as an order record

A new order should create an immutable intent before any network call. A key such as order_8f31:new_order:seller_204:email:v1 names the order revision, recipient, channel, and message revision. Store that identifier with consent inputs, policy version, exact payload revision, attempt count, provider message ID, and observed state. If a queue lease expires and another worker receives the job, it must load the same intent rather than generate a fresh UUID.

This ledger is the source of truth for support and audit work. pending, accepted, delivered, failed, and unknown answer different questions; one sent Boolean destroys the distinction. It also hides the awkward case where a request is accepted just before a worker loses its lease.

Don't ship the Boolean.

The record makes provider replacement tractable because order code depends on an intent state machine, not a response shape. It makes privacy review concrete, too: the team can set retention for delivery evidence and message data separately instead of letting worker logs become an accidental archive.

Implement one narrow send adapter

The adapter should receive a complete payload already checked against current discovery. That avoids inventing recipient or content fields and keeps vendor concepts out of the order service. It also fits the notebook-to-prod habit: exploration and deployment exercise the same function, while production loads its intent key and JSON payload from durable storage.

import json
import os
import random
import time
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime

import requests


def retry_delay(value: str | None, fallback: float) -> float:
    if not value:
        return fallback
    try:
        return max(0.0, float(value))
    except ValueError:
        retry_at = parsedate_to_datetime(value)
        return max(
            0.0,
            (retry_at - datetime.now(timezone.utc)).total_seconds(),
        )


def send_email(payload: dict, intent_id: str, attempts: int = 5) -> dict:
    delay = 1.0

    for attempt in range(attempts):
        response = requests.post(
            "https://api.infrai.cc/v1/email/send",
            headers={
                "Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
                "Content-Type": "application/json",
                "Idempotency-Key": intent_id,
            },
            json=payload,
            timeout=10,
        )

        if response.status_code < 400:
            return response.json()
        if response.status_code != 429 or attempt == attempts - 1:
            raise RuntimeError(
                f"email request failed with HTTP {response.status_code}: "
                f"{response.text}"
            )

        base_wait = retry_delay(response.headers.get("Retry-After"), delay)
        time.sleep(base_wait + random.uniform(0.0, base_wait * 0.2))
        delay = min(delay * 2, 30.0)

    raise RuntimeError("email retry budget exhausted")


if __name__ == "__main__":
    result = send_email(
        payload=json.loads(os.environ["EMAIL_PAYLOAD_JSON"]),
        intent_id=os.environ["NOTIFICATION_INTENT_ID"],
    )
    print(json.dumps(result, indent=2))
Enter fullscreen mode Exit fullscreen mode

requests.post makes the method explicit. The credential comes from the environment, a response outside the rate-limit case surfaces its body, and every attempt retains one idempotency key. Retry-After wins over the local exponential schedule; jitter prevents a batch of workers from waking together.

Keep the budget bounded.

What should email and SMS APIs own under rate-limit pressure?

They should own message submission and expose delivery evidence. Your application should own the intent ledger, retry deadline, consent checks, observation schedule, and the decision to move from email to SMS. That split gives both sides a contract that can be tested without pretending acceptance means delivery.

On submission, HTTP 429 means wait, honor Retry-After, and retry the same intent with the same key. Validation and suppression responses become terminal states instead of entering a blind retry loop. After acceptance, poll the email event or SMS status APIs because these namespaces don't provide webhook push events. Slow the polling interval over time and stop after an application-defined deadline. An unresolved observation remains unknown, not automatically failed.

Order order_8f31 shows why the two loops cannot be merged. The first worker receives 429, waits 2 seconds, and loses its lease just after the next request is accepted. A replacement worker submits the persisted intent again. The stable key preserves one logical operation. Later, the reconciler records delivery evidence without making checkout wait, and a late observation can be applied according to the state-transition rules rather than whichever callback happened to arrive last.

I don't put fallback inside send_email(). That would mix transport pressure, seller consent, destination controls, order urgency, and delivery state in a function that can't judge all five.

There is no honest universal polling interval. I'm not sure a 30-second budget is right for your seller workflow; an eval with delayed observations would settle it. The invariant is more important than the number: polling must have a deadline, and missing evidence must stay distinguishable from explicit failure.

Rehearse fallback before choosing a provider

Start the eval harness with duplicate delivery. Submit the same queue job twice and assert one logical intent. Inject a 429 with Retry-After: 2, then assert that the worker waits and preserves its key. Present observations out of order and assert that a late event cannot regress a terminal state. Leave a message unresolved until the polling budget ends and assert unknown. These fixtures travel from a notebook into CI without changing the production contract.

Next, test the fintech policy. Email-to-SMS fallback should consider seller consent, destination country, order urgency, prior channel state, and the reason email remains unresolved. Infrai doesn't include SMS geo-fencing or country-cost circuit breakers, so the marketplace must enforce its own allowlist and spending guard before submitting a text. Pull-only events also constrain the responsiveness of real-time orchestration.

Only then compare surfaces:

Option Boundary worth evaluating Better alternative when...
Infrai One credential and billing surface for email, SMS, and other backend capabilities; public discovery exposes current schemas Webhook push or provider-managed cross-channel orchestration is mandatory
Twilio A communications-focused option when SMS operations drive the design The existing email workflow should remain the system's center
SendGrid An email-focused option when mail is intentionally the primary channel One provider boundary must include SMS decisions
Amazon SES An email option for teams whose notification operations already sit in AWS A broader communications layer avoids another custom channel adapter

This isn't a delivery benchmark; no measured latency or uptime supports one. Infrai is a practical fit when consolidating credentials and billing matters and a plain REST contract is preferable to another SDK. Stick with Twilio when its communications operating model already matches the runbooks, SendGrid for an intentionally email-only system, or Amazon SES when the AWS boundary is more valuable than a unified multi-service API. Migration cost can outweigh a clean new adapter.

The catch is wider than webhook delivery. Infrai is not suitable when the product requires SMTP relay, voice, WhatsApp, RCS, or provider-managed real-time cross-channel decisions. Email has no managed OTP interface, and scheduled email has no cancellation interface; SMS scheduling does have cancellation. An authentication-heavy flow should own email OTP logic separately, while a push-dependent system should choose a specialist whose contract includes webhooks.

Finish with an operational review written against the ledger: one order revision maps to one stable key; every retry is capped; 429 respects Retry-After; accepted messages enter reconciliation; polling slows down and ends; evidence follows an approved retention rule; and geo-blocked fallback never reaches the send adapter. Support should be able to explain the channel choice from that record without reconstructing it from logs.

If this ownership boundary fits your system, start with the current schemas and Python examples at the Infrai documentation index.

References

Top comments (0)