Short answer: treat an email API 429 as delayed work, not a failed contact form. Keep the marketplace queue, template version, and message ID unchanged; honor Retry-After; then retry from a durable worker with capped exponential backoff, jitter, and an explicit age limit.
The useful boundary is ownership. The team that owns the support template should define how long that message class may wait and what happens when it expires. Shared infrastructure should enforce the transport rules. That arrangement makes a seller payout question different from a general product question without duplicating the retry machinery.
This is a queueing problem before it is an HTTP problem.
How should Node.js handle email API 429 rate limit errors?
The request path should validate the form, assign a support queue, render the approved template, and enqueue a delivery job. A worker then calls the email API. When the API returns 429, the worker schedules that same job for later instead of making the browser submit the form again.
In the routing record, preserve a correlation ID, message ID, queue, template owner, template version, rendered payload, attempt count, and creation time. The payload should be immutable after enqueueing. A template edit made while a job is delayed should not silently change the promised reply.
The response status has a precise meaning: the service is asking the client to slow down. RFC 6585 defines 429, and Retry-After can express either a number of seconds or an HTTP date. Neither fact supplies an unlimited retry budget. The worker still needs a maximum age, an attempt boundary, and a dead-letter path.
SPF is relevant to sender authorization, but it does not increase an API's request allowance. Authentication, reputation, and throttling belong to related but separate parts of deliverability.
A retry policy that keeps ownership intact
Put the policy in a transport-independent module. In a Node.js service, the queue library and HTTP client will be JavaScript; the decisions below are deliberately shown as plain Python because the state transitions are easier to inspect in an eval harness. The same inputs should produce the same class of outcome in either implementation.
from dataclasses import dataclass
from datetime import datetime, timezone
import email.utils
import random
@dataclass
class DeliveryJob:
message_id: str
queue: str
template_owner: str
template_version: str
attempt: int = 0
def retry_delay(headers: dict[str, str], attempt: int, now: datetime) -> float:
value = headers.get("retry-after")
if value:
try:
return max(0.0, float(value))
except ValueError:
try:
retry_at = email.utils.parsedate_to_datetime(value)
if retry_at.tzinfo is None:
retry_at = retry_at.replace(tzinfo=timezone.utc)
return max(0.0, (retry_at - now).total_seconds())
except (TypeError, ValueError, OverflowError):
pass
# The cap limits queue age; jitter stops workers sharing one wake-up time.
base = min(300.0, 2 ** min(attempt, 8))
return base * random.uniform(0.5, 1.0)
def decide(job: DeliveryJob, status: int, headers: dict[str, str], now: datetime):
if status in (200, 201, 202):
return ("completed", 0.0)
if status == 429 and job.attempt < 8:
return ("retry", retry_delay(headers, job.attempt, now))
return ("dead_letter", 0.0)
The five-minute value and eight-attempt boundary are policy examples, not universal truths. A queue owner should choose them against the maximum acceptable delivery age. They must be visible configuration, covered by tests, and included in the runbook. A fixed sleep is tempting because it is short. It also makes a fleet wake up together, recreate the burst, and receive another 429. Jitter trades exact timing for a less synchronized load pattern.
In production, the worker should acknowledge the queue only after it has recorded the provider outcome. If the provider supports an idempotency key, derive it from the stable message ID rather than generating one for every attempt. If a timeout occurs after the request may have been accepted, local code cannot prove that no email exists. Mark the outcome as unknown and send it through reconciliation; do not blindly create a fresh logical message.
Do not retry the form submission itself.
This design is a poor fit for a synchronous, low-volume script that has no durable queue and no replay operator. In that case, adding a queue introduces state and maintenance work that may exceed the value of delayed delivery. The trade-off is deliberate: durable scheduling improves recovery and auditability, while it also requires ownership for dead letters, retention, and reconciliation.
Who owns the template and who owns the retry?
Template ownership should live in the routing record, but transport behavior should live in shared infrastructure. The template owner controls recipients, required fields, language variants, version retention, and the acceptable age for that message class. The platform layer controls rate-limit accounting, delay scheduling, idempotency, metrics, and redaction.
That split prevents a common operational mismatch: a generic helper applies one delay to every message while queue owners assume urgent cases have priority. A buyer-safety report may need a shorter age budget than a low-priority seller question. The worker can support both with named policies such as urgent_safety and standard_support; it should not execute arbitrary policy code stored in a form field.
The route must remain stable during a retry. If the job changes from seller_support to billing merely because its first delivery was throttled, the system has transformed a transport event into a business decision. Those are different events and deserve different audit records.
How do you test recovery instead of trusting it?
An eval harness should feed the policy a sequence containing a success, a 429 with seconds, a 429 with an HTTP date, a malformed header, a missing header, a timeout after possible acceptance, and a permanent 4xx. Assertions should cover more than a final status: queue and template owner remain unchanged, delays stay within bounds, attempts stop at the policy boundary, and one message ID does not create two logical deliveries.
Run the policy tests in every change, then exercise the real queue adapter with a fake transport before release. Track 429s by queue and template version, delay duration, attempt number, age at successful delivery, permanent failures, and dead-letter volume. Correlation IDs belong in logs; form contents, tokens, and authorization material do not.
The operational checklist is prose because it belongs in the runbook: identify the queue and template owner, preserve the original message ID, parse both legal Retry-After forms, cap the backoff, add jitter, enforce both attempts and job age, record provider acceptance separately from local acknowledgement, and require an explicit decision for uncertain replays. If those fields are absent, the application is not ready to call a 429 routine backpressure.
The implementation is intentionally boring. Delay the same job. Observe it. Stop when its contract expires.
References
- RFC 6585, Additional HTTP Status Codes: https://datatracker.ietf.org/doc/html/rfc6585
- RFC 7231, HTTP Semantics and
Retry-After: https://datatracker.ietf.org/doc/html/rfc7231#section-7.1.3 - RFC 7208, Sender Policy Framework (SPF): https://datatracker.ietf.org/doc/html/rfc7208
- MDN Web Docs, 429 Too Many Requests: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/429
Top comments (0)