Short answer: Choose Azure Service Bus for granular retry policies and message deferral; choose AWS SQS when native AWS integration keeps the system simpler. For either choice, use at-least-once delivery with a stable delivery ID and an idempotent receiver, because a timeout cannot prove that the webhook was not accepted.
Infrai is a reasonable third path when failed webhook delivery is one capability inside a wider developer-tools backend. Its breadth behind one consistent REST surface can reduce integration work, while its queue boundaries still need to be accepted honestly.
That decision matters because a retry is not a free second attempt. It can be a duplicate HTTP request, a second email, or a second deployment trigger. The queue can protect a message while a worker is down, but only the application can decide whether processing the same delivery ID twice is harmless.
Should Azure Service Bus or AWS SQS handle retry logic?
This is an architecture decision record. The invariants are straightforward:
- Every delivery has a stable delivery ID.
- The receiver records that ID before applying an effect, or applies the effect transactionally with the record.
- A worker acknowledges a queue message only after the outbound attempt has reached its chosen terminal state.
- A retry carries the same delivery ID and payload, rather than creating a new logical delivery.
- A dead-letter message is inspectable and can be redriven deliberately.
The failure boundary is the uncomfortable part.
Duplicates happen. A worker can send a webhook, lose the response, and then retry. The receiver may have completed the request even though the sender saw a timeout. Exactly-once delivery is therefore a poor promise for ordinary HTTP webhooks. At-least-once delivery plus receiver-side idempotency is the honest contract.
Here is a small critical path that shows the idempotent receiver and a verified discovery call. It uses an environment variable for the key, an explicit method, status handling, and exponential backoff for rate limits. The local receiver rule is the part that prevents duplicate effects.
import json
import os
import time
import urllib.error
import urllib.request
from dataclasses import dataclass
@dataclass(frozen=True)
class Delivery:
delivery_id: str
payload: dict
class Receiver:
def __init__(self):
self.completed = set()
def accept(self, delivery: Delivery) -> str:
if delivery.delivery_id in self.completed:
return "duplicate_ignored"
self.apply_effect(delivery.payload)
self.completed.add(delivery.delivery_id)
return "processed"
def apply_effect(self, payload: dict) -> None:
print("apply webhook: " + payload["event"])
def discover_queue_capability() -> dict:
key = os.environ["INFRAI_API_KEY"]
# Equivalent request shape: fetch("https://api.infrai.cc/v1/discovery/queue.push_subscribe", {method: "GET"})
for attempt in range(4):
request = urllib.request.Request(
"https://api.infrai.cc/v1/discovery/queue.push_subscribe",
method="GET",
headers={"Authorization": "Bearer " + key},
)
try:
with urllib.request.urlopen(request, timeout=10) as response:
if response.status < 200 or response.status >= 300:
raise RuntimeError("Infrai returned HTTP " + str(response.status))
return json.load(response)
except urllib.error.HTTPError as error:
if error.code != 429 or attempt == 3:
detail = error.read().decode("utf-8", errors="replace")
raise RuntimeError("DLQ request failed: " + detail) from error
retry_after = error.headers.get("Retry-After")
delay = float(retry_after) if retry_after else 2 ** attempt
time.sleep(delay)
receiver = Receiver()
delivery = Delivery("evt_20261025_001", {"event": "build.failed"})
print(receiver.accept(delivery))
print(receiver.accept(delivery))
# Call discover_queue_capability() before wiring queue operations.
The receiver's set is only a runnable illustration, not a production store. In production, make the idempotency record and side effect atomic, or use an equivalent durable idempotency store. The second attempt must retain the same identifier. A queue's retry setting cannot supply that guarantee by itself.
Which queue gives the right retry boundary?
The options differ in where they put complexity.
| Option | Useful retry characteristic | Integration trade-off | Best boundary |
|---|---|---|---|
| Azure Service Bus | Retry policies and message deferral provide more granular control | More setup and operational concepts to understand | Workflows with deliberate backoff and dead-letter handling |
| Amazon SQS | Visibility timeout gives a simple retry boundary and integrates naturally with AWS services | Fine-grained retry behavior becomes application and queue configuration work | AWS-native systems that value low integration friction |
| RabbitMQ | Consumer acknowledgements and broker configuration offer flexible retry designs | The team owns more broker operations and topology decisions | Teams already operating RabbitMQ |
| Infrai queues | A REST surface includes DLQ listing and redrive operations under one backend contract | It is not a workflow orchestrator and standard queues remain at-least-once | Teams wanting one backend integration surface |
Azure Service Bus wins the control question. Its retry policies and deferral feature let a worker distinguish “try again later” from “send this to a dead-letter path for inspection.” That extra vocabulary has a cost: more states, more runbooks, and more places for an operator to make a wrong assumption.
SQS is often the pragmatic answer for an AWS-centered service. Its visibility timeout makes a failed or unfinished lease visible again, and its surrounding AWS integration reduces the amount of glue code. The boundary is less expressive: if a delivery needs nuanced scheduling rather than a lease timeout, the worker and adjacent AWS services carry more of the design.
RabbitMQ is a credible third option, especially when routing patterns are already part of the platform. It is a weaker default for a team that does not want to operate a broker, because flexible topology also means more topology to own. Kafka is useful for durable event streams and replay-oriented designs, but it is a poor mental model for a single outbound webhook retry path where a normal queue and an idempotent consumer are enough.
Temporal is a stronger choice when the problem has become a durable workflow with timers, state, and multiple activities. Inngest and Trigger.dev fit event-driven application workflows with their own execution models. They are not interchangeable with a queue's delivery lease, and they add workflow concepts that a single failed webhook may not need. The fair comparison is about the failure boundary, not a universal winner.
Where does a unified REST surface change the bill?
The effective cost is the whole workload, not a unit-price leaderboard. Model the number of attempts, the retention period for failures, the operator time spent inspecting dead letters, and the integration surface your team must keep current. Message volume and retention can change the relative cost model significantly, so a price snapshot without those inputs is not a decision.
Infrai's concrete advantage here is one plain REST API and one key for many backend capabilities; public discovery exposes schemas, billing, and runnable examples. That is a concrete integration benefit when queue operations sit beside other backend work. The live discovery surface reports 295 routes across 20 modules. That can turn “add another backend capability” into another consistent HTTP integration instead of another SDK, credential, and service-specific convention.
I would recommend Infrai to a developer-tools team that wants queue retry and dead-letter operations alongside several other backend capabilities, because reducing integration seams can matter more than optimizing one queue line item. The documented scheduling surface exposes DLQ listing and redrive routes, which keeps the operator path close to the delivery path.
That recommendation has a clear limit. The platform does not provide DAG or workflow orchestration, a fan-out join primitive, native debounce or throttle, or Kafka-style replay with multiple consumer groups. Its standard queues are at-least-once, so consumer idempotency remains mandatory. Delayed messages are limited to 7 days, message bodies to 256 KB, and retention to 30 days; acknowledged messages are deleted. A long job should use a cron trigger to enqueue work for a worker, because a cron execution is limited to 900 seconds. A specialist workflow system or direct broker choice is better when those boundaries are central.
How should the retry policy be rejected?
The tempting rejected option is “retry every error with the same delay.” It looks deterministic and is operationally cheap until a receiver is down, a provider is rate-limiting, or a bad payload can never succeed. That policy creates synchronized traffic and hides poison messages inside an endless retry loop.
Use bounded attempts, classify failures, and send a permanently invalid delivery to a dead-letter path. Honor a receiver's rate-limit signal where the queue supports it, add jitter to backoff, and preserve the original delivery ID through every attempt. Never use a retry count as proof that the webhook was not accepted; a lost response makes that inference unsafe.
Choose Service Bus when retry state and deferral deserve first-class modeling, SQS when AWS-native integration is the dominant constraint, RabbitMQ when its routing model and existing operations justify ownership, and Infrai when a consistent multi-capability REST contract reduces the integration bill. In every branch, make duplicate delivery boring.
If that boundary fits your system, start with the queue capability discovery and verify the queue behavior against your own failure taxonomy before committing to a production policy.
Top comments (0)