DEV Community

FluxH91
FluxH91

Posted on

Admission Control for User Reminders — Provider 429 Recovery Across Push, Email, and SMS

Short answer: In a Node.js user-reminder system, put each subscriber's push, email, and SMS work behind durable, channel-partitioned queues; on provider HTTP 429, honor Retry-After or apply jittered backoff, and preserve the same delivery key through DLQ redrive. The deciding constraint is delivery guarantees: the system must survive duplicate work and delayed work without turning one subscriber reminder into several messages.

For an e-commerce application, the web request should record the reminder and return; a periodic worker can claim due reminders, while a separate periodic cleanup expires old delivery records. Neither task belongs on an open request. Push, email, and SMS then leave through different queue partitions because a rate limit on one provider must not stall the other two.

This design provides at-least-once processing inside the application and suppresses duplicate sends with a delivery ledger. It does not promise magical exactly-once delivery across a remote provider boundary. That distinction is the whole decision.

Decision and invariants

The scheduler creates one logical delivery for each tuple of reminder_id, subscriber_id, and channel. A stable key derived from that tuple follows the work through scheduling, queueing, retry, dead-letter handling, and redrive. The ledger is the authority for whether the application may attempt a send; the queue is transport, not truth.

Four invariants make the design testable:

  1. A due reminder can be claimed more than once, but every claim resolves to the same delivery key.
  2. Push, email, and SMS have separate concurrency and rate budgets.
  3. A 429 response never consumes the permanent-attempt budget when the provider supplies a usable retry time; it moves the delivery to a future eligibility time.
  4. Redrive reuses the original delivery key and payload version rather than creating fresh logical work.

The third invariant deserves suspicion. MDN documents that a 429 response means too many requests in a given period and may include Retry-After, but it doesn't make every provider's quota model identical. A global token bucket can still be wrong when limits are scoped by account, destination, campaign, or some combination. I'm not sure what scope any reader's provider contract uses; the provider documentation and response headers must settle that before choosing partition keys.

Keep the state machine small: scheduled, ready, sending, deferred, delivered, dead, and expired. Store next_eligible_at, attempt_count, the last response class, and a payload version. Don't store only an opaque error string. Operators need to distinguish throttling, a permanent destination rejection, and exhaustion caused by local policy without scraping logs.

How should a Node.js queue back off user reminder email and SMS after provider 429s?

The Node.js runtime is not the hard part. The control loop is: reserve capacity before dispatch, send once, classify the result, and make the next state durable. If a 429 includes a valid Retry-After, honor it; otherwise use capped exponential backoff with jitter and a provider-specific floor. A delayed queue or scheduled-work facility should hold deferred items without occupying a worker or keeping a timer alive in one process.

Do not sleep inside the consumer.

Sleeping holds concurrency while doing no work, disappears on process restart, and encourages a burst when many timers wake together. Persist next_eligible_at, release the queue lease, and let the scheduler make the delivery visible later. Jitter should spread clients across a window; it should not schedule before an explicit server delay. The retry calculation belongs in one shared policy module so email, SMS, and push adapters cannot quietly invent different meanings for the same response.

Backpressure must begin before the provider call. A token bucket or leaky-bucket gate at dispatch time protects the known budget, while adaptive concurrency reacts to observed throttling. On 429, reduce that partition's dispatch pressure and defer the item. On successful responses, raise pressure cautiously rather than jumping to the previous peak. This is feedback control — the queue length measures demand, not permission to send. The subscriber dimension matters too: ten reminders for one shopper should not monopolize a partition merely because they became due in the same second. Fair scheduling across subscriber keys, followed by channel and provider admission control, prevents a hot account from delaying everyone else. It also makes cancellation useful because an order pickup reminder that becomes irrelevant while deferred can be marked expired before any external call.

Failure boundaries and architecture options

There is no single “retry count” that captures these failures. A provider throttle is temporal; an invalid destination is usually permanent for that address; a queue lease timeout says the worker outcome is unknown; and a deployment interruption may happen after the provider accepted a request but before the ledger recorded success. Each boundary needs a state transition, not a generic catch block.

Option Delivery behavior 429 behavior Operational cost Valid boundary
Direct call from the web request Coupled to request lifetime; client retries can duplicate intent Delays or fails the request Low until traffic spikes Small internal tools where a missed reminder is acceptable
One shared FIFO queue Durable work, but head-of-line blocking couples channels One constrained provider can slow all traffic Moderate A single channel with one rate domain
Partitioned queues plus a delivery ledger At-least-once work with application-level duplicate suppression Defers only the affected rate partition Higher state and observability burden Multi-channel reminders with explicit delivery guarantees
Transactional outbox plus partitioned dispatch Couples reminder creation and enqueue intent in one database transaction Same dispatch controls as above Highest database and relay complexity Commerce writes where losing enqueue intent is unacceptable

The chosen design is the last row when reminder creation already occurs in the commerce database. Write the reminder and outbox record atomically, relay the outbox to channel queues, and retain the delivery ledger through the redrive horizon. The catch is storage growth and cleanup coordination: deleting deduplication records too early makes an old message look new, while retaining every attempt forever turns an operational table into an archive by accident.

The narrow crash window is unavoidable unless the remote provider participates in the same transaction or accepts a stable idempotency key with defined semantics. A worker can send successfully and crash before committing delivered; retrying may send twice. Local locking doesn't close that window. Where the provider supports a documented idempotency facility, pass the stable delivery key and preserve it on every retry. Where it does not, the honest guarantee is at-least-once attempt with best-effort duplicate suppression, and product behavior should tolerate a rare duplicate rather than claim exactly once.

Duplicates still happen.

For reminders, tolerance can include collapsing pending work for the same event and channel, attaching a version to editable content, and checking cancellation immediately before dispatch. None of those checks proves the remote side did not accept an earlier call. They reduce avoidable duplicates and stale sends; they don't rewrite the distributed-systems boundary.

Critical path, DLQ, and redrive

The following Python models the transition that a Node.js consumer should implement around its own queue and database libraries. It deliberately leaves transport adapters generic. The important part is the ordering: claim a delivery key, acquire channel capacity, classify 429, persist the next eligible time, and acknowledge the queue item only after the state change commits.

from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from email.utils import parsedate_to_datetime
import random


@dataclass(frozen=True)
class Delivery:
    key: str
    channel: str
    provider_partition: str
    attempt: int
    payload_version: int


def retry_delay_seconds(retry_after: str | None, attempt: int) -> float:
    if retry_after:
        if retry_after.isdigit():
            return max(0.0, float(retry_after))

        retry_at = parsedate_to_datetime(retry_after)
        if retry_at.tzinfo is None:
            retry_at = retry_at.replace(tzinfo=timezone.utc)
        return max(0.0, (retry_at - datetime.now(timezone.utc)).total_seconds())

    cap = min(900.0, 2.0 ** min(attempt, 10))
    return random.uniform(cap / 2.0, cap)


def handle_delivery(delivery: Delivery, queue, ledger, limiter, provider) -> None:
    claim = ledger.claim(delivery.key, delivery.payload_version)
    if claim.already_delivered or claim.expired:
        queue.ack(delivery.key)
        return

    partition = (delivery.channel, delivery.provider_partition)
    permit = limiter.reserve(partition)
    if not permit.granted:
        ledger.defer(delivery.key, permit.available_at, reason="local_rate_limit")
        queue.reschedule(delivery.key, permit.available_at)
        return

    response = provider.send(delivery.channel, claim.payload, delivery.key)
    if response.status == 429:
        delay = retry_delay_seconds(response.headers.get("Retry-After"), delivery.attempt)
        eligible_at = datetime.now(timezone.utc) + timedelta(seconds=delay)
        ledger.defer(delivery.key, eligible_at, reason="provider_rate_limit")
        limiter.observe_throttle(partition, eligible_at)
        queue.reschedule(delivery.key, eligible_at)
        return

    if 200 <= response.status < 300:
        ledger.mark_delivered(delivery.key, response.receipt_id)
        limiter.observe_success(partition)
        queue.ack(delivery.key)
        return

    if response.is_permanent_rejection:
        ledger.mark_dead(delivery.key, reason=response.error_class)
        queue.move_to_dlq(delivery.key)
        return

    eligible_at = datetime.now(timezone.utc) + timedelta(
        seconds=retry_delay_seconds(None, delivery.attempt)
    )
    ledger.defer(delivery.key, eligible_at, reason="transient_delivery_failure")
    queue.reschedule(delivery.key, eligible_at)
Enter fullscreen mode Exit fullscreen mode

This code treats the DLQ as evidence, not a trash can. A dead-letter record should retain the delivery key, immutable payload reference, channel, provider partition, classified reason, attempt history, and timestamps. Access controls matter because reminder payloads and destinations can contain personal data; store the least data needed to diagnose and redrive, and apply the same retention policy used for the delivery ledger.

Redrive is a controlled state transition. Select a reason class, estimate the resulting volume, verify that the cause no longer applies, and release work through the normal admission controller. Never dump the whole DLQ back into the ready queue. That recreates the burst that caused a 429 and erases the distinction between recovered and newly due reminders.

An operator should be able to answer four questions from metrics without reading individual logs: how old is the oldest ready reminder, what fraction of attempts are deferred by local admission versus provider 429, how many logical deliveries are dead by reason, and how often does the ledger suppress a duplicate claim? Alert on sustained age and growing dead-letter volume, not on a single 429. Throttling is a normal control signal; missed delivery windows are the customer-visible failure.

Testing needs more than a mock that returns success. Use a deterministic clock and force sequences such as 429, 429, accepted; a missing Retry-After; a numeric header; an HTTP-date header; cancellation during deferral; lease expiry after the external call; and redrive of an item whose delivery key is already marked complete. Property tests can verify that next_eligible_at never moves before an explicit retry time and that redrive never changes the logical key. During deployment, drain or shorten leases deliberately and keep old payload decoders until their queued versions are gone.

Rejected option and its valid use case

Calling providers directly from the scheduling process was rejected because scheduling and delivery have different failure domains. The periodic scan should decide what is due and emit durable intent; it should not wait through provider backoff, and it should not let an email throttle postpone SMS or push. A single shared worker pool was rejected for the same coupling reason.

Direct delivery is still suitable when reminders are best-effort, volume is tightly bounded, the user can safely retry, and the business accepts loss or duplication during a process restart. Stick with one queue when there is truly one channel and one documented rate domain; the partitioned design adds ledgers, delayed scheduling, retention jobs, dashboards, and redrive controls that a small internal notification may not justify.

For the e-commerce case, however, use a periodic scheduler plus transactional outbox, partition dispatch by channel and provider rate scope, and preserve one delivery key through backoff, DLQ, and redrive. Judge the system by reminder age, cancellation correctness, and duplicate effects — not by how fast it can empty a queue after the provider has already asked it to slow down.

References

Top comments (0)