DEV Community

PantaleonShaw8478
PantaleonShaw8478

Posted on

Property Management Email 429 Rate Limit Handling for Contact Routing

Short answer: Handle email API 429 rate limit errors at a durable queue boundary: honor Retry-After, retry with bounded jitter, and keep the property-management queue and idempotency key unchanged.

A 429 from an email API should pause delivery at the queue boundary, not trigger an immediate retry in every request handler. For a property-management contact form, keep the selected support queue and an idempotency key in durable storage, honor Retry-After when present, and retry with bounded exponential backoff plus jitter. The form request should acknowledge accepted work only after that record exists.

This ordering matters because a tenant asking about a leaking roof should reach maintenance, while a lease question belongs with leasing. A throttle response is temporary pressure; it is not permission to lose that routing decision or to send the same notice five times.

Keep the decision boring.

How should I handle 429 rate limit errors in email delivery?

HTTP 429 means the server is refusing to process the request because a rate limit was exceeded. RFC 6585 defines the status and says the response may include Retry-After; that value can be a delay in seconds or an HTTP date. Treat it as a scheduling hint, and cap it against an operator-defined maximum. A missing header is not a reason to hammer the endpoint.

The useful unit of work is a durable delivery record, for example: contact_id, queue, template_revision, attempt_count, next_attempt_at, and delivery_key. The template revision belongs with the record because ownership is part of the routing contract. If the maintenance team owns the template, a later editor should not silently change the message that an already accepted form is waiting to send. The trade-off is deliberate: copying more metadata into the record makes migrations and audits easier, but it also makes schema ownership part of the delivery design.

I initially treated throttling as a transport concern. The failure mode was broader: retrying from the HTTP handler made traffic burst again when several workers woke together, and an operator could not tell whether a message was pending, sent, or merely accepted by the provider. The fix was to make state transitions explicit: pending, deferred, sent, and failed.

A queue-owned retry loop

The handler writes the contact and delivery record in one transaction, then returns an acknowledgement to the form system. A worker claims due records with a lease. It sends one request, records the provider response, and releases or extends the lease in the same operational path. The delivery key must remain stable across retries; generating a new key inside the retry loop defeats deduplication.

Here is the decision logic in Go. The same rules apply in a Node.js worker: parse the status and Retry-After, calculate a due time, and persist it before the item becomes visible again.

type Delivery struct {
    Attempts    int
    NextAttempt time.Time
    DeliveryKey string
}

func retryAt(now time.Time, d Delivery, status int, retryAfter *time.Duration) (time.Time, bool) {
    if status != 429 {
        return time.Time{}, false
    }
    if retryAfter != nil {
        delay := *retryAfter
        if delay > 30*time.Minute {
            delay = 30 * time.Minute
        }
        return now.Add(delay), true
    }

    // Full jitter prevents workers from forming one retry wave.
    base := 1 << min(d.Attempts, 10)
    return now.Add(time.Duration(rand.Intn(base)) * time.Second), true
}

func min(a, b int) int {
    if a < b {
        return a
    }
    return b
}
Enter fullscreen mode Exit fullscreen mode

In production, the random source needs to be seeded appropriately, and the worker needs a total attempt or age limit. A 429 is retryable, but an unbounded retry is a storage problem waiting to become an incident. Persist the response class and a redacted request identifier so the next person can reconstruct the decision without logging message content.

The 30-minute cap is a policy choice, not a protocol rule. A support queue with a short response objective may need a dead-letter review sooner; a low-priority newsletter queue may tolerate a longer age limit. Choose that boundary from the business response objective, then test it.

Where should template ownership live?

Put ownership in configuration that is reviewed like code, then copy the selected revision into each delivery record. Do not let a queue name alone choose the template at send time. Queue membership can change while a deferred message waits, and that creates a surprising handoff between property operations teams.

The routing rule should be deterministic and testable. For example, emergency, maintenance, leasing, and accounting can be the only accepted destinations; an unknown category should enter a review queue rather than fall through to a general mailbox. The worker can still use one generic email API interface, but the business decision remains owned by the application team that maintains the contact taxonomy.

This is also where deliverability enters the design. SPF is an authorization mechanism for hosts allowed to send mail for a domain; it does not repair a queueing bug or prove that a message was read. Keep authentication and reputation checks separate from the 429 state machine, and monitor them separately.

How do you verify a fix without replaying customer mail?

Start with a replayable fixture containing a few contact categories and a fixed delivery key. In a staging environment, return 429 responses with a short Retry-After, then verify that the record remains singular, its next_attempt_at moves forward, and no worker claims it before that time. Test a date-form Retry-After, a malformed value, a missing value, and a delay above the cap.

The production dashboard should expose queue age, deferred count, attempts by status class, and the oldest due record. Alert on age and backlog growth, not on every individual 429; a brief, correctly handled throttle is expected. An alert becomes actionable when work cannot catch up within the maximum accepted delay for that support queue.

Rollback is a configuration change: stop assigning new work to the affected route, preserve existing delivery records, and drain them at a controlled rate after the limit clears. Do not delete the records to make a dashboard green. If the template revision is wrong, pause the route and correct ownership before replaying; the stable delivery key prevents a corrective run from becoming a duplicate send.

A good runbook ends with two questions: can we prove which queue owned this message, and can we prove why this attempt was deferred? If either answer requires searching application logs for a vanished request, the retry design is not finished.

It failed the audit.

References

Top comments (0)