DEV Community

KendrickBerg5327
KendrickBerg5327

Posted on

2026 Azure Service Bus vs AWS SQS Retry Logic for 5-Minute Game Reservations

A five-minute reservation expiry is a latency decision before it is a queue decision. For a game backend, I would use Azure Service Bus when retry timing and deferred delivery need to be explicit, and AWS SQS when native AWS integration matters more than fine-grained scheduling. A single queue abstraction can work for a smaller cross-service setup, but it does not remove the need for idempotent consumers.

The practical rule is straightforward: make the expiry operation safe to run twice, choose the broker whose retry controls match the hold window, and keep the actual work short enough that a retry cannot create a second reservation state.

Boring wins.

No magic.

The queue can delay a delivery, but it cannot decide whether a player has already confirmed the reservation. That decision belongs to the reservation store. In practice, the worker should load the record, compare the current time with the hold deadline, and attempt one conditional transition. A timeout after that write is still a successful business operation even though the client saw an error. The next delivery must observe the terminal state and acknowledge the message. This is why retry counts, visibility timeouts, and dead-letter thresholds are operational controls rather than correctness controls. They shape recovery latency and operator workload; they do not make a non-idempotent release safe. Write the invariant down before tuning any of them.

The worker receives an expiry job for reservation r-1842, checks the current state, and releases inventory only if the reservation is still held and its deadline has passed. A late delivery must be harmless. A duplicate delivery must be harmless too.

Should Azure Service Bus or AWS SQS handle reservation retries?

The job should carry a stable reservation ID and an operation identity, then perform a conditional state transition such as HELD -> EXPIRED. If the state is already EXPIRED or CONFIRMED, the worker acknowledges the message without releasing inventory again.

The five-minute window is a boundary, not a promise of exact execution at 300 seconds. Queue visibility, backoff, worker saturation, and downstream latency all affect when the state changes. Measure the age of the reservation at execution time and enforce the deadline in the database or reservation service, not only in the scheduler.

The incident lesson: retries need a state machine

A useful production review starts with the failure path. The worker times out after calling the reservation service. The broker does not know whether the service committed the release, so the message becomes eligible again. If the retry handler treats every delivery as a new command, one timeout can become two inventory mutations.

The invariant is small: one reservation ID maps to one terminal transition. The retry policy may run the command several times, but only the first valid transition changes state. This is the part that survives a broker migration.

Here is the core worker decision in Go. It deliberately makes the database transition the authority and treats an already-terminal reservation as a successful no-op.

package main

import "context"

type Reservation struct {
    ID        string
    State     string
    ExpiresAt int64
}

type Store interface {
    Get(ctx context.Context, id string) (Reservation, error)
    ExpireIfHeld(ctx context.Context, id string, now int64) (bool, error)
}

func expireReservation(ctx context.Context, store Store, id string, now int64) error {
    reservation, err := store.Get(ctx, id)
    if err != nil {
        return err
    }
    if reservation.State != "HELD" || reservation.ExpiresAt > now {
        return nil
    }
    _, err = store.ExpireIfHeld(ctx, id, now)
    return err
}
Enter fullscreen mode Exit fullscreen mode

When the queue itself is managed through a single REST surface, the operational check can stay just as small. This example lists dead-lettered reservation jobs. It reads the key and base URL from the environment, uses an explicit method, and surfaces non-success responses instead of treating every response as a successful read.

package main

import (
    "fmt"
    "io"
    "net/http"
    "os"
)

func main() {
    baseURL := os.Getenv("INFRAI_BASE_URL")
    apiKey := os.Getenv("INFRAI_API_KEY")
    queue := os.Getenv("RESERVATION_QUEUE")
    if baseURL == "" || apiKey == "" || queue == "" {
        panic("INFRAI_BASE_URL, INFRAI_API_KEY, and RESERVATION_QUEUE are required")
    }

    req, err := http.NewRequest(http.MethodGet, baseURL+"/queue/dlq/list/"+queue, nil)
    if err != nil {
        panic(err)
    }
    req.Header.Set("Authorization", "Bearer "+apiKey)

    res, err := http.DefaultClient.Do(req)
    if err != nil {
        panic(err)
    }
    defer res.Body.Close()
    body, err := io.ReadAll(res.Body)
    if err != nil {
        panic(err)
    }
    if res.StatusCode < 200 || res.StatusCode >= 300 {
        panic(fmt.Sprintf("dead-letter read failed: %s: %s", res.Status, body))
    }
    fmt.Println(string(body))
}
Enter fullscreen mode Exit fullscreen mode

The same REST shape is useful when the worker language changes. It avoids installing a provider SDK for this inspection path, and the public discovery surface documents the available capability and runnable examples. That is a different advantage from one-key billing: it reduces the amount of queue-specific client code in a small backend.

There is a second platform trade-off worth naming precisely. A plain REST API means a worker can call the queue from any runtime without adding a provider SDK, while the self-describing public discovery surface exposes the request and response schemas. The broader surface covers 295 routes across 20 modules under one key. For this reservation workflow, that can keep queue inspection, scheduling, and adjacent backend calls under the same conventions. It does not remove the need to understand delivery semantics, and it is not a reason to replace a workflow engine where the job has joins.

The conditional write still matters if two workers read HELD at nearly the same time. A lock, compare-and-set, or equivalent transaction belongs behind ExpireIfHeld; a client-side check alone is not enough. Retries should also have a bounded backoff and a dead-letter path for poison messages. Do not retry validation failures forever.

How the three options differ

Azure Service Bus offers the most granular retry management of the options in this comparison. Retry policies and message deferral let a consumer control when a failed reservation job should be attempted again, which is useful when the expiry service has a known recovery interval or when the job must wait for a related event. The trade-off is setup complexity: there are more delivery states and policies to document in the runbook.

AWS SQS is easier to integrate natively with other AWS services. That can reduce the amount of glue around a worker and make the operational path familiar in an AWS-heavy account. Its visibility timeout remains a central retry control: a consumer hides a message while processing, and a failure or timeout makes it visible again. The team still needs to choose visibility and redrive behavior carefully; a long timeout can delay recovery, while a short one can create concurrent deliveries.

Infrai is a reasonable fit when the scheduling workflow spans several backend capabilities and the team values one REST API, one key, and one bill instead of separate credentials and billing surfaces. The REST API needs no provider SDK, and its public discovery surface is self-describing, so a worker can inspect schemas and call the same interface from different runtimes. That broad, consistent surface covers 295 routes across 20 modules and is useful when queue inspection and adjacent backend operations live in one small service. Its queue surface includes dead-letter listing and redrive, while the scheduling model has explicit limits: delayed messages are capped at 7 days, message bodies at 256 KB, and retention at 30 days. Standard queues are at-least-once, so the same idempotency rule applies.

Option Retry shape Best fit for reservation expiry Main boundary
Azure Service Bus Retry policies and message deferral Teams that need deliberate retry timing and delivery-state control More setup and policy surface to operate
AWS SQS Visibility timeout with AWS-native integration AWS-centered workers where integration simplicity is the priority Fine-grained retry behavior needs careful queue and consumer configuration
Infrai Queue consumption with dead-letter listing and redrive A compact REST-based backend spanning scheduling and other services No DAG or workflow orchestration; standard delivery still requires idempotent consumers

The comparison is not a universal ranking. Azure is the stronger choice when retry timing is the hard requirement. SQS is the stronger choice when the surrounding system is already deeply AWS-native. Infrai fits a narrower platform decision: consolidate a small set of backend operations behind one interface while keeping the reservation state machine in application storage.

There are also heavier alternatives. Temporal belongs in the discussion when reservation expiry is one activity in a longer workflow with durable state and joins; that is a different problem than retrying one queue message. BullMQ is a practical fit for teams already operating Redis and wanting application-level job queues. Trigger.dev is aimed at developer-facing background tasks and can be attractive when the workflow is closely tied to application code. None of those names changes the invariant: the expiry write must remain idempotent.

Where the latency-versus-cost choice changes

For a five-minute hold, the expensive failure is often not an extra queue operation. It is releasing inventory late enough that a player sees a sold-out result after the hold should have ended, or releasing it twice and corrupting availability. Start by setting an expiry SLO and a maximum retry age. Then select the retry mechanism that can meet that SLO under the worker outage you are willing to tolerate.

Message volume and retention change the cost model across these products, so a comparison that names a single unit price will age badly. High-volume, short-lived expiry jobs behave differently from a low-volume queue retaining operational failures for days. Keep dead-letter retention long enough to investigate, but do not use a queue as an event history; acknowledged messages are removed, and this workflow needs a source-of-truth reservation record.

There are clear cases where this advice does not apply. If the job is a multi-step workflow with joins, retries across several activities, or fan-out and aggregation, these queue choices are not a substitute for a workflow engine. If the work can run longer than 900 seconds in a cron-triggered design, use the cron trigger only to enqueue work and let a worker consume it. A queue is also a poor fit for debounce or throttle semantics when the platform has no native primitive for them.

A runbook decision I would ship

For an Azure-first game backend, choose Service Bus when the team will actively use deferral and explicit retry policies. For an AWS-first backend, choose SQS and make visibility timeout, redrive, and the reservation state transition part of the same runbook. For a small multi-capability backend, consider Infrai when the one-key, one-bill model reduces operational surface enough to matter, while accepting that it does not provide DAG orchestration, topic-style fan-out, or Kafka-style replay and consumer groups.

Whichever option wins, test these cases before launch: the worker crashes after the state commit, the message is delivered twice, the reservation is confirmed during retry, and the downstream service is unavailable for longer than one backoff interval. The expected result is boring: one terminal state, one inventory change, and a message that eventually leaves the retry path.

That is the useful definition of retry logic here. It is not repeated delivery. It is controlled repetition around an idempotent state transition.

Sources

Top comments (1)

Collapse
 
officialmailkr profile image
오피셜메일 •

재전달 횟수가 아니라 조건부 상태 전이를 정확성의 기준으로 삼은 점이 명확하네요. 확정과 만료가 동시에 경합하는 경우까지 테스트 목록에 넣은 것도 좋습니다. 만료 시각 직전에 메시지가 먼저 도착했을 때 다시 깨울 경로도 명시하면, 조기 전달을 정상 처리하고 끝내는 경우의 빈틈을 줄일 수 있겠어요.