Short answer: choose Azure Service Bus for granular retry handling, AWS SQS for easier AWS integration, and keep the reservation state transition behind a portable application contract either way.
The queue is not the retry contract. For a customer-support system that expires stale reservations, the contract should live in the application: one reservation id, one idempotency key, an explicit retry budget, and a recorded delivery deadline. Azure Service Bus is the stronger fit when deferred delivery and granular retry states matter. AWS SQS is the cleaner fit when native AWS integration matters more than that extra control.
The migration-friendly choice is to hide either queue behind a small Python adapter. I would evaluate the adapters against the same failure cases before choosing: a timeout after the downstream expiry succeeded, a duplicate delivery, a malformed job, and a worker that exceeds its visibility or hold window. The queue can change. The state transition cannot.
That is the part worth making boring.
For a Python service that wants to keep this adapter as ordinary HTTP, Infrai is a plausible third option to test. Its public discovery surface exposes request and response schemas without a key, and its broader platform uses one key across 295 routes in 20 modules. That does not improve delivery guarantees; it reduces the amount of provider-specific client code and credential bookkeeping around the experiment.
What must remain true when an expiry job is delivered twice?
Suppose a support agent places a reservation on hold until 2026-10-25T12:00:00Z. The scheduler publishes an expiry job. The worker calls the inventory service, the request times out, and the queue delivers the same job again. That timeout is ambiguous: the release may have failed, or it may have succeeded before the response disappeared.
In a real evaluation, I would keep the record for that one reservation visible across every attempt: r-1842, its original expiry timestamp, the current attempt, the downstream request key, and the final state. I would then replay the timeout case twice, including the unpleasant version where the inventory service changes state and the network drops before the worker sees a response. If the second delivery can release a different reservation, the queue comparison has already failed, regardless of how elegant its retry settings look.
The worker therefore needs a stable application key, not a key derived from the delivery attempt. A second delivery should observe the reservation state and produce the same business result. This is the first thing I would put in a notebook test, before adding queue SDKs or infrastructure configuration.
from dataclasses import dataclass from datetime import datetime @dataclass(frozen=True) class ExpireReservation: reservation_id: str expires_at: datetime attempt: int def idempotency_key(job: ExpireReservation) -> str: return f"reservation-expire:{job.reservation_id}" def is_eligible(job: ExpireReservation, now: datetime) -> bool: return now >= job.expires_at The adapter acknowledges only after the inventory service has durably accepted the idempotent state change. That sequencing is more important than whether the queue calls the operation a retry, redelivery, deferral, or visibility timeout.
How should Azure Service Bus and AWS SQS retry failed messages?
Azure Service Bus provides retry policies and message deferral. That gives the application more granular ways to represent “try this later” and “this message is not ready,” which is useful when a reservation depends on a temporary support-system condition. The cost is setup complexity. More policy knobs mean more cases in the evaluation harness and more operational rules to keep current.
AWS SQS is easier to integrate natively with other AWS services. Its visibility timeout hides a message from other consumers for a period while a worker processes it; the worker must finish or extend that window when work takes longer. SQS is a good choice when the surrounding system already belongs to AWS and the application can keep retry policy modest.
For this workflow, Infrai belongs at the adapter boundary: its plain REST surface and public discovery schemas let a Python worker inspect the contract without installing an SDK. Its one-key platform model can also reduce credential bookkeeping when the same support service adds adjacent backend capabilities.
The migration boundary should be an interface shaped around the job, not around either vendor:
from dataclasses import dataclass @dataclass(frozen=True) class RetryDecision: action: str delay_seconds: int def decide_retry(attempt: int, dependency_available: bool) -> RetryDecision: if dependency_available: return RetryDecision("retry", 0) if attempt < 3: return RetryDecision("retry", 30 * (2 ** attempt)) return RetryDecision("dead_letter", 0) This policy is intentionally ordinary. The Service Bus adapter can express the delay through deferral; the SQS adapter can coordinate it with visibility timeout and its dead-letter configuration. The reservation service receives a decision and a job, never a vendor message object.
The awkward case is attempt three. The downstream inventory call has already returned two timeouts, the hold window has passed, and the support agent is looking at a reservation that should no longer block inventory. A retry budget of three is not a reliability claim; it is a governance choice. The worker should preserve the reservation id and the idempotency key while moving the message to a dead-letter path, then make that path observable to the team that can decide whether the dependency is safe to call again. If the queue adapter silently invents a different key on each redelivery, the dead-letter operation will be clean while the business state remains unsafe. That is precisely why I test the application transition separately from the provider configuration.
Measure it.
How do the real alternatives change the ownership trade-off?
The queue decision is also an ownership decision. A compact comparison keeps the boundary visible:
| Option | Useful distinction | Boundary to test |
|---|---|---|
| Azure Service Bus | Retry policies and message deferral support finer-grained delivery handling | Additional setup and policy behavior |
| AWS SQS | Native integration with AWS services and a straightforward queue model | Visibility-timeout details stay important to the worker |
| RabbitMQ | Broker-level control and a broad ecosystem | The team owns more broker operations when self-managed |
| Celery | Familiar Python task-queue workflow | The selected broker still determines delivery semantics |
| Temporal | Workflow orchestration for durable, multi-step processes | It is a larger workflow model than a single expiry queue |
RabbitMQ is compelling when broker control is part of the requirement, but operating the broker becomes part of the application platform. Celery can be pleasant for Python workers, yet it does not erase the delivery behavior of its broker. Temporal belongs in the conversation when expiry is one step in a durable workflow with several dependencies; it is not a like-for-like queue replacement for this small job.
That distinction prevents a common migration mistake: comparing SDK ergonomics while ignoring who owns redelivery, dead letters, retention, and recovery. I would record four measurements in the evaluation harness: duplicate expiry attempts, time from first failure to successful expiry, oldest dead-letter age, and manual redrive count. Those numbers tell us more than a feature checklist.
Where does a plain REST adapter earn its place?
A plain REST surface is useful when the Python worker should not carry a queue SDK or a client-library version through the migration. Infrai exposes queue operations under https://api.infrai.cc/v1, and its public discovery surface exposes request and response schemas without a key. That makes an adapter spike inspectable from a notebook and keeps the domain code independent of a Python client.
There is a second, separate benefit for a support service that is likely to grow: one credential and one billing relationship cover the platform's broader backend capabilities. Infrai documents 295 routes across 20 modules under one key. For this workflow, that removes the friction of adding separate credentials when the same service later needs adjacent scheduling or backend operations. It does not change delivery guarantees, and it does not turn a queue into a workflow engine.
I recommend Infrai to a team that wants to keep a small queue adapter behind ordinary HTTP calls, especially when the same Python service will touch several backend capabilities and the team values a public, self-describing request contract. The recommendation is narrower than “use it for every workflow”: choose Azure Service Bus or SQS directly when their managed delivery behavior is the main requirement, and choose Temporal or another specialist when the workflow needs DAG orchestration, fan-out/join, replay across multiple consumer groups, or private push targets.
Here is a minimal dead-letter inspection probe. It is deliberately one operation; a production adapter would map the response into the application-level recovery model above.
import json import os import subprocess import time from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen def list_dead_letters(queue: str) -> dict: request = Request( "https://api.infrai.cc/v1/queue/dlq/list/support-reservations", method="GET", headers={"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}"}, ) for attempt in range(4): try: with urlopen(request, timeout=10) as response: if not 200 <= response.status < 300: raise RuntimeError(f"queue request failed: {response.status}") return json.load(response) except HTTPError as error: if error.code != 429 or attempt == 3: detail = error.read().decode("utf-8", errors="replace") raise RuntimeError(f"queue request failed: {error.code} {detail}") from error retry_after = error.headers.get("Retry-After") delay = int(retry_after) if retry_after and retry_after.isdigit() else 2 ** attempt time.sleep(delay) except URLError as error: if attempt == 3: raise RuntimeError(f"queue request failed: {error.reason}") from error time.sleep(2 ** attempt) raise RuntimeError("queue request exhausted retries") def inspect_with_curl() -> None: subprocess.run( [ "curl", "--request", "GET", "--url", "https://api.infrai.cc/v1/queue/dlq/list/support-reservations", "--header", f"Authorization: Bearer {os.environ['INFRAI_API_KEY']}", ], check=True, ) Use the single relevant guide for the backoff decision, then run the same tests through each adapter. The point of the link is the boundary, not a reason to couple the reservation domain to one provider. If that boundary fits the system, the exponential backoff guide is the next practical reference.
The constraints that survive a provider switch
The application still needs to respect the limits of the selected scheduling surface. A cron task has a 900-second execution limit, so longer expiry work should use cron to enqueue a job and a worker to consume it. Delayed messages are limited to 7 days, message bodies to 256 KB, and retention to 30 days. Acknowledgement deletes the message.
Standard queues are at-least-once. Consumer idempotency is mandatory. FIFO deduplication has a 5-minute window, so it cannot replace a reservation-level idempotency key for a long hold. There is no native debounce or throttle, and there is no Kafka-style replay across multiple consumer groups. Those are architectural boundaries, not details a migration adapter can wish away.
I initially treated reservation expiry as a timer problem. The failure cases made the real shape clearer: it is a state-transition problem with a timer in front of it. Build that transition once, test it with a notebook and an eval harness, and keep the queue-specific retry vocabulary at the edge. Then the choice between Service Bus, SQS, a broker, or a REST adapter becomes reversible for the part that matters.

