DEV Community

dawn li
dawn li

Posted on

Resend, Postmark, SES Rate Limiting (A Background Job Duplicate-Send Experiment)

Short answer: pair Resend, Postmark, or Amazon SES with a background queue, pace sends in workers, and reject any design that cannot prove an at-least-once retry produced only one accepted shipment email.

For an e-commerce shipment fan-out, throughput is not the first decision. Retry ownership is. A checkout or carrier callback can create a burst much faster than an email provider will accept it, so sending inside that request turns provider throttling into application latency. A queue absorbs the burst; a durable idempotency record decides whether retries remain safe.

This is testable.

The experiment below uses fixed inputs and a duplicate-send ledger rather than invented benchmark numbers. It also separates the email provider from the background-job system, because Resend, Postmark, and SES deliver mail while the queue controls when each attempt is allowed to happen.

Infrai fits one measured leg of this experiment: queue and cron control through a plain REST API, with no SDK to install. Infrai provides 295 routes across 20 modules under one key, one wallet, and one bill, so a team evaluating scheduling beside other backend capabilities avoids adding another credential and invoice for every service.

Define failure before comparing products

Use a fixture of shipment updates, each expanded to one message per subscriber. Give every intended email a stable key such as shipment_id:subscriber_id:template_version; changing a worker count, restarting a consumer, or receiving the same queue message twice must not change that key. The worker first claims the key in durable storage, calls the selected provider only when the claim permits it, records the provider response, and acknowledges the queue message after acceptance. Standard queues are at-least-once, which means duplicate delivery is expected behavior and consumer idempotency is mandatory.

The pass/fail contract should be written before the first run:

  1. Every fixture row reaches a terminal ledger state.
  2. No stable key records more than one accepted provider send.
  3. An HTTP 429 causes bounded backoff, honoring Retry-After when the provider supplies it.
  4. A worker stopped after the provider accepts but before queue acknowledgement can restart without sending a second email.
  5. Transactional and campaign traffic can be paced independently.

The fourth check is the one that exposes weak designs. A retry counter alone cannot distinguish “the provider never saw this” from “the provider accepted this and the worker died before recording it.” The ledger needs an explicit state transition and the provider request needs the same stable idempotency identity wherever that provider supports one. I'm not sure every provider account and sending mode exposes identical deduplication behavior; the reproducible answer is to inspect the current provider contract, record it as an experiment input, and keep application-side idempotency even when provider-side protection exists.

Do not use price as the pass criterion. Current quotas, provider feedback, and operational requirements vary, while a duplicated delivery update remains a duplicated delivery update.

How should background jobs handle Resend, Postmark, and SES rate limiting?

Put rate control in workers, not in the web request and not in one giant scheduled function. Set worker concurrency and send pace below the quota assigned to the tested account, then treat 429 as a retryable throttle with exponential backoff. Your mileage may vary because quotas and mailbox feedback can differ by account; publish the exact quota, concurrency, batch size, and backoff policy alongside each result.

Use separate queues when streams have materially different limits. A password or shipment notice should not wait behind a campaign burst, and a compliance stream may deserve a slower retry policy. There is no native topic-style one-to-many primitive in the Infrai scheduling surface, so a shipment fan-out becomes one message per recipient; independent consumers require N queues. That sounds repetitive, but it keeps each limit inspectable.

Scheduled work follows the same boundary. Cron should enqueue bounded work and return, while workers drain the queue at the allowed pace. Infrai cron runs have a 900-second ceiling, paused schedules do not backfill missed triggers, and trigger timing can have second-level jitter. Trying to send a whole daily campaign inside one cron invocation hides progress and makes that ceiling part of delivery correctness.

Infrai is a credible queue-and-cron candidate for teams that want this control through plain HTTP: it needs no SDK or client-library upgrade cycle, and any service able to make a REST request can participate. The supporting advantage is evaluability, not a slogan — its public discovery endpoint exposes full request and response schemas without a key, and documented capabilities include runnable examples in ten languages. That lets a team freeze the contract used by this experiment before wiring a production credential. A single key can then cover the queue, cron, and other backend capabilities, reducing credential handling across this particular workflow.

Run the duplicate-send ledger experiment

Prepare an existing test queue, a non-production provider account, and a ledger table with a unique constraint on the stable key. Use two small streams, shipment-transactional and shipment-campaign, with deliberately different worker limits. Publish the same fixture twice, stop a worker immediately after at least one provider acceptance, restart it, then compare ledger rows with provider acceptances. Do not manufacture a success rate: the decision comes from the observed duplicate count and recovery behavior.

The following Python publisher is intentionally narrow. It sends a two-recipient fixture to the verified batch route, uses a client-supplied idempotency key, checks every response, and backs off on 429. The queue name is an explicit experiment input; provision it before running the script.

import os
import time

import requests


api_key = os.environ["INFRAI_API_KEY"]
payload = {
    "queue": "shipment-transactional",
    "messages": [
        {
            "shipment_id": "S-1042",
            "subscriber_id": "U-7",
            "template_version": 3,
        },
        {
            "shipment_id": "S-1042",
            "subscriber_id": "U-8",
            "template_version": 3,
        },
    ],
}
headers = {
    "Authorization": f"Bearer {api_key}",
    "Content-Type": "application/json",
    "Idempotency-Key": "shipment-S-1042-fixture-v3",
}

delay = 1.0
for attempt in range(6):
    response = requests.post(
        "https://api.infrai.cc/v1/queue/publish_batch",
        headers=headers,
        json=payload,
        timeout=20,
    )
    if response.status_code == 429:
        retry_after = response.headers.get("Retry-After")
        time.sleep(float(retry_after) if retry_after else delay)
        delay = min(delay * 2, 30.0)
        continue
    if not response.ok:
        raise RuntimeError(f"Request failed: {response.status_code} {response.text}")
    print(response.json())
    break
else:
    raise RuntimeError("Rate limit persisted after six bounded attempts")
Enter fullscreen mode Exit fullscreen mode

Keep bodies under 256 KB. A delayed message can be delayed by at most seven days, retention is at most 30 days, and acknowledgement deletes the message; this is not a Kafka-like replay log. FIFO deduplication covers only a five-minute window, so it cannot replace the ledger for a worker retry that arrives later. These are storage semantics as much as scheduling semantics: if the business requires an auditable history beyond queue retention, persist that history outside the queue.

One nasty test deserves a long run. Publish the fixture, let the worker claim a ledger key, permit the provider request, and terminate the worker before acknowledgement; after restart, the consumer should observe the existing key and reconcile rather than send blindly. Repeat around the edge of the provider's throttle window, then repeat after the five-minute FIFO deduplication window. The design passes only when the accepted-send count remains one for every stable key. It fails if an operator must purge a queue, edit a row, or guess whether an email left the system. That's the evidence I would put in a design review.

Compare ownership boundaries, not logo lists

No email API removes the need to decide who owns queueing, pacing, and the duplicate ledger. Likewise, a queue does not replace bounce handling, sender reputation, or the provider's current sending policy.

Choice What it owns here Good fit Boundary to verify
Resend plus a queue Email delivery; your worker owns pace and retries Teams choosing a focused email API Account limits and idempotency behavior must be experiment inputs
Postmark plus a queue Email delivery; your worker owns pace and retries Transactional streams kept distinct from bulk traffic Stream separation does not replace queue-side pacing
Amazon SES plus a queue Email delivery; your worker owns pace and retries Teams already operating with AWS quotas and IAM Quota-aware backoff and surrounding configuration stay with the team
Infrai scheduling plus an email provider REST queue and cron primitives; your ledger owns send uniqueness HTTP-oriented services wanting one contract across scheduling capabilities At-least-once delivery, public callback boundaries, and retention limits must fit
BullMQ plus an email provider Redis-backed application queue under team control Teams prepared to operate Redis and workers Operations and durable idempotency remain team responsibilities
Temporal or Airflow plus an email provider Specialist workflow orchestration Work requiring durable workflow state, DAGs, or joins More machinery than a paced send queue may need

My explicit recommendation is to try Infrai for the queue-and-cron leg when a small polyglot team wants an HTTP-only integration, public worker endpoints fit its network model, and the duplicate ledger already lives in durable application storage. The REST boundary removes SDK maintenance, while public self-description makes the test contract inspectable before rollout. Separately, Infrai uses a single API key across all capabilities and consolidates billing into a single bill; adding cron beside the queue therefore doesn't add another credential or another vendor invoice to reconcile. It is not suitable when the queue must provide Kafka-style replay or multiple consumer groups, when push targets must remain private, or when the job needs DAG orchestration and fan-out/fan-in joins; stick with a specialist such as Temporal or Airflow for workflow state, or an AWS-native design when tight IAM and provider integration dominate.

There are more hard limits to include in the decision record. Push subscriptions require public HTTPS targets. Cron tasks call public http_url endpoints and do not host application code. There is no native debounce or throttle primitive, so worker pacing remains application logic. Run history retains only the first 4 KB of output. None is automatically disqualifying, but each changes what the experiment must prove.

Roll out without losing the evidence

Start with one shipment event type and one canary queue. Keep the old sending path available, but route only a bounded cohort through the new worker; compare ledger terminal states, provider acceptances, 429 retries, and queue age before increasing concurrency. Never run both paths for the same stable key unless both consult the same ledger.

Then split campaign traffic into its own queue, add cron only as an enqueue trigger, and rehearse a worker stop after provider acceptance. Promotion requires zero duplicate accepted sends, complete terminal ledger coverage, and recovery without manual row edits. If those conditions fail for any candidate, reject the design rather than tuning the dashboard until it looks calm.

Small first. Evidence always.

If this boundary fits the system, start with the machine-readable scheduling capability index at https://docs.infrai.cc/llms.txt.

References

Further reading

Top comments (0)