Short answer: for logistics event notifications, put email and SMS attempts behind one durable delivery ledger, suppress a recipient after a definitive invalid-recipient signal, and retry rate-limited work with bounded backoff under the same idempotency key.
The bill starts with attempts, not rows. A retained delivery record is usually small, while one bad address can provoke another email attempt for every dispatch, delay, and proof-of-delivery event associated with that recipient. The useful calculation is therefore events x eligible channels x attempts, split by outcome, before anyone argues about databases or queue products. I keep enough history to explain suppression and deduplicate retries; I don't keep every response body forever.
That choice favors delivery reliability over maximal forensic retention. The catch is that deleting detailed attempt history narrows the evidence available during a later dispute, so a regulated workflow with a mandated audit period should retain immutable event data for that period rather than use the shorter operational window described here.
The first reliability number is paid attempts
Start with four counters per reporting window: accepted business events, channel candidates, provider submissions, and retained bytes. Those counters separate a fan-out problem from a retry problem. If 100,000 shipment events produce 160,000 channel candidates and 190,000 submissions, the extra 30,000 submissions are the term to investigate; the numbers are an illustrative dataset, not a benchmark or a promise about any provider. Storage still matters, but it should be modeled rather than waved away. Let E be event rows, A be attempt rows, S_e and S_a be their average stored sizes, and R_e and R_a be their retention windows. For a steady daily workload, retained bytes are proportional to E * S_e * R_e + A * S_a * R_a. The dominant term is whichever product is larger in your own measurements. I'm not sure which term dominates in a given logistics system until those four values come from production telemetry; mileage varies with payload size, retry policy, and how aggressively response data is normalized. This measurement has to precede the retention decision because a team that counts only stored rows may optimize a cheap normalized ledger while leaving duplicate external submissions untouched, whereas a team that counts only submissions may keep bulky diagnostic payloads long after they can change an operational decision. The four counters make that disagreement visible without pretending every workload has the same dominant cost.
| Record | Keep it for | Reason | Deliberate omission |
|---|---|---|---|
| Business event | The required business or audit window | Rebuild intent and prove what triggered delivery | Provider response bodies |
| Delivery state | The deduplication and investigation window | Decide whether a channel may run again | Repeated copies of message content |
| Suppression evidence | While the address or number remains unusable, subject to policy | Prevent known-invalid recipients from re-entering fan-out | Unrelated provider metadata |
| Attempt detail | A short operational window | Diagnose delay and tune backoff | Long-lived transient headers |
The change that moves the attempt term is earlier suppression, not more elaborate compression: ingest the definitive bounce or invalid-recipient result once, update recipient state atomically, and make eligibility checks read that state before enqueueing future work. The state needs a reason, effective time, source event identifier, and policy version; without those fields, an operator can see that a recipient is blocked but can't explain why or decide whether a later correction should reactivate it.
I would stop keeping full transient response payloads after the operational investigation window, while retaining normalized outcome, timestamps, attempt count, and correlation identifiers. That saves unbounded repetition in the data layer, but the cost is real: if a dispute arrives after the payload expires, the team can reconstruct state transitions and decisions, not reproduce every byte returned during delivery.
How should event notifications handle email and SMS API rate limits?
A 429 is scheduling information. It isn't permission to create a second logical delivery. The worker should record the current attempt, honor a valid server-provided delay when the integration contract defines one, otherwise calculate bounded exponential backoff with jitter, and release the same logical delivery for a later attempt. A global retry loop is risky because one busy channel or destination can occupy all workers; partitioning budgets by channel and, where justified, destination keeps unrelated shipment alerts moving.
Same key. Later slot.
The idempotency key belongs to the business delivery, not the process that happens to execute it. A practical input is the immutable business event ID plus recipient ID, channel, and template version. Store a unique digest of that tuple before making an external submission. If two workers race, only the winner may send; the other observes the existing state and exits. This rule also covers a worker restart between queue receipt and delivery-state lookup.
from dataclasses import dataclass
from hashlib import sha256
import random
@dataclass(frozen=True)
class Delivery:
event_id: str
recipient_id: str
channel: str
template_version: str
@property
def key(self) -> str:
raw = ":".join(
(self.event_id, self.recipient_id, self.channel, self.template_version)
)
return sha256(raw.encode("utf-8")).hexdigest()
def retry_delay_seconds(attempt: int, cap: int = 300) -> float:
bounded_attempt = min(max(attempt, 0), 10)
ceiling = min(cap, 2 ** bounded_attempt)
return random.uniform(0, ceiling)
def deliver(delivery, ledger, sender, now):
state = ledger.claim(delivery.key, now)
if state.already_complete or state.recipient_suppressed:
return
result = sender.submit(delivery)
if result.accepted:
ledger.mark_submitted(delivery.key, result.message_id, now)
elif result.rate_limited:
delay = result.retry_after or retry_delay_seconds(state.attempt_count)
ledger.release_at(delivery.key, now + delay)
elif result.invalid_recipient:
ledger.suppress_and_complete(
delivery.key, delivery.recipient_id, result.reason, now
)
else:
ledger.complete_without_retry(delivery.key, result.reason, now)
That example intentionally leaves ledger and sender as generic interfaces. The important contract is transactional: claim must have a uniqueness guarantee, and suppress_and_complete must not leave the attempt retryable after recipient suppression. In a Node.js service, the same state transitions belong around the promise that submits the message; changing the language doesn't change the concurrency problem.
Keep the retry limit explicit. An infinite queue is just retained uncertainty — and in a logistics operation, stale “driver arriving” notifications can be worse than a recorded terminal failure. The terminal policy should consider event expiry as well as attempt count, because ten retries in one minute and ten retries across two days have different business meaning.
One authority owns the bounce decision
The dangerous design is a list of strings checked somewhere near the email client. It creates two authorities: the notification ledger says work is pending, while the list says the recipient is invalid. Races follow. A suppression record should instead participate in the same eligibility decision that creates channel work, with normalized identity rules chosen for the channel and a traceable source result.
Email adds an authentication layer that is separate from recipient validity. DMARC defines policy and reporting built on identifier alignment; it helps a domain state how receivers should handle messages that fail the applicable checks, but it doesn't replace the application's bounce ledger. Treating an authentication-policy failure as proof that one recipient address is invalid would mix domain-level sending posture with recipient-level reachability. Keep those states distinct.
SMS has a different boundary. Browser WebOTP can help a user agent receive a specially formatted one-time code with user consent, and MDN documents that it requires a secure context. It is not a general delivery receipt for logistics notifications. Don't let a front-end OTP completion signal mutate the suppression state for operational SMS; the evidence answers a different question.
This is where a compact failure taxonomy earns its keep. “Rate limited” means schedule again under the same key. “Invalid recipient” means suppress and stop future eligible work. “Accepted” means the upstream submission step completed, not that a human read the message. “Expired business event” means stop, even if another technical attempt could be made. Mixing any two of these states inflates attempts or hides a delivery gap.
Retries stop.
Two workers reveal the concurrency contract
Unit tests should freeze the backoff inputs, assert the cap, and verify that an invalid recipient never returns to the queue. The higher-value test starts two workers with the same delivery key and forces them to contend at the ledger boundary. Exactly one submission should be possible. Then restart the winning worker after the sender accepts but before the local state advances; the expected recovery behavior must follow the sender contract and the ledger's reconciliation design, rather than blindly submitting a fresh delivery.
Rollout needs observable invariants. Track candidates, claims, submissions, rate-limited results, suppressions, expirations, and duplicate-claim rejections as separate counters. Alert on relationships, such as submissions growing faster than candidates or suppressed identities continuing to create claims. Avoid using one blended “failure rate”: it can't distinguish protective throttling from bad recipient data, and the two conditions require different operators and different fixes.
Deploy the new state transition behind a policy version, shadow the eligibility decision without sending extra messages, and compare decisions before enforcing suppression. Short and boring is good here. A warehouse dispatch feed can generate bursts at shift changes, so a test with evenly spaced events misses the queue pressure that exposes lock contention and unfair retry scheduling.
The limitation is that this ledger is not suitable when a workflow requires synchronous, all-or-nothing delivery across email and SMS; external communication systems don't form one transaction with the application database. Redesign that requirement around independently observable outcomes. A shorter attempt-retention window is also the wrong choice when legal or contractual rules require raw evidence for longer. In that case, stick with the required immutable archive and separate it from the hot delivery table, accepting the storage and governance cost.
The five-question release gate
Choose the smallest design that can answer five questions without consulting provider dashboards: what business event caused this notification, which recipient and channel were eligible, which logical delivery key controlled duplication, why the next attempt ran when it did, and what evidence changed the recipient to suppressed. If the data model can't answer one, adding another retry library won't repair it.
For the logistics case, I choose a durable ledger, per-channel retry budgets, bounded jittered backoff, and recipient suppression driven by definitive outcome evidence. I also choose shorter retention for verbose transient details than for normalized state. The trade is fewer bytes and clearer hot-path queries in exchange for less late forensic detail. Teams that must preserve raw delivery evidence should make the opposite retention choice while keeping the same idempotency and state-transition rules.
No vendor name resolves the hard part. The reliability boundary is the atomic decision that a delivery may run, coupled to evidence that it must stop.
References
- RFC 7489, “Domain-based Message Authentication, Reporting, and Conformance (DMARC)”: https://datatracker.ietf.org/doc/html/rfc7489
- MDN, “WebOTP API”: https://developer.mozilla.org/en-US/docs/Web/API/WebOTP_API
Further reading
The two primary references above cover the standards boundaries used here: domain-level email authentication policy and browser-mediated receipt of SMS one-time codes. They should be read as boundary definitions, not as substitutes for an application delivery ledger.
Top comments (0)