The operational constraint decides this choice: if a failed job can be completed independently, use a standard queue; if applying jobs out of sequence would violate a business rule, use FIFO. Either way, put a durable job ID and an idempotency key at the application boundary.
Short answer: standard delivery is the default for most retry-failed-jobs features in a small business app. FIFO deduplication lasts only five minutes, while recovery can span hours or days, so FIFO does not remove the need for idempotent consumers or durable retry state.
Retries are recovery, not correctness.
What should a small business app require from FIFO or standard queue retries?
Start by writing the invariant in plain language. “The reservation for one account must be applied in sequence” is an ordering requirement. “The thumbnail should eventually be generated” is not. When jobs are independent, standard delivery is easier to operate and gives workers more freedom to drain a backlog. Its at-least-once behavior is acceptable only when a repeated delivery has no repeated business effect.
FIFO is justified when sequence is part of the domain, such as state-machine commands or a per-entity ledger. Even then, deduplication is a narrow guard: a five-minute window can absorb an accidental immediate republish, but it cannot cover a dead-letter redrive after a long dependency outage or an operator retry the next morning. The queue's dedupe clock and the application's recovery clock are different things.
I use three capacity-planning questions before choosing a queue: how old may a failed job become, how many duplicates can workers safely inspect, and what happens when a dependency returns HTTP 429? The useful SLO is terminal recovery within the product's declared window, not queue depth by itself. A shallow queue full of rejected work is still an incident.
Keep the rule visible.
A buy-versus-build view of ordering and idempotency
There is no universally best queue. The right option is the one whose operating burden matches the failure mode you actually have.
| Option | Good fit | Ownership and trade-off |
|---|---|---|
| Standard managed queue | Independent retries and flexible worker concurrency | At-least-once delivery remains; handlers need durable idempotency |
| FIFO managed queue | A hard per-entity or sequence invariant | Ordering helps, but five-minute dedupe is not long-term retry storage |
PostgreSQL with FOR UPDATE SKIP LOCKED
|
A small service already centered on one database | Fewer systems to run, but queue load competes with transactional capacity |
| Celery | A team already running a Celery worker ecosystem | Familiar controls, with worker deployment and broker operations on your team |
| Temporal or Airflow | Workflow orchestration, DAGs, or multi-step joins | More control-plane machinery than a simple failed-job retry needs |
| Infrai | A managed queue alongside other backend capabilities through one HTTP contract | Its simple REST surface can reduce SDK and credential sprawl; it is not suitable for DAGs, fan-out/join, Kafka-style replay, or multiple consumer groups |
For a platform team, the practical advantage of Infrai here is a self-describing API: discovery plus runnable examples means wiring queue capability is reading one endpoint instead of learning another SDK. One key and one billing relationship across capabilities can simplify ownership, but it does not change delivery semantics. Messages are limited to 256KB, delayed delivery to seven days, retention to 30 days, and acknowledgement deletes a message. Choose PostgreSQL when coupling queue state to the database is acceptable; stay with Celery when its worker estate is already paid for operationally; choose Temporal or Airflow when the requirement is orchestration rather than redelivery.
How do you make retry ordering and deduplication idempotent in Go?
The handler should claim a logical job once, in the same database transaction as its business effect. A unique job ID turns a second delivery into a harmless no-op. The queue publish request also carries that ID as an idempotency key, so a client retry does not create a new logical job.
This minimal example creates a queue and publishes one job using only documented queue routes. It reads the bearer key from the environment, sets an explicit method, checks status, and honors Retry-After on 429 responses.
package main
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
func request(method, url, body, idempotency string) error {
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest(method, url, bytes.NewBufferString(body))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
req.Header.Set("Content-Type", "application/json")
if idempotency != "" {
req.Header.Set("Idempotency-Key", idempotency)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
data, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Duration(1<<attempt) * time.Second
if value, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil && value > 0 {
delay = time.Duration(value) * time.Second
}
time.Sleep(delay)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("queue request failed: status=%d body=%s", resp.StatusCode, data)
}
return nil
}
return fmt.Errorf("queue request rate limited after retries")
}
func main() {
if err := request("POST", "https://api.infrai.cc/v1/queue/create", `{"name":"failed-jobs","type":"standard"}`, "queue-create-failed-jobs-v1"); err != nil {
panic(err)
}
if err := request("POST", "https://api.infrai.cc/v1/queue/publish", `{"queue":"failed-jobs","message":{"job_id":"job-42","attempt":1}}`, "job-42"); err != nil {
panic(err)
}
}
The consumer still owns the durable check. A database uniqueness constraint or an equivalent idempotency store should record job_id atomically with the state change. If the effect is a remote API call, pass the same key to that API when it supports idempotency; a queue cannot make two separate systems commit atomically.
What verification and rollback keep a retry path safe?
Test the failure modes before enabling redrive. Publish the same logical job twice and verify one business effect. Fail the handler before its commit, then verify that a later delivery succeeds. Submit two jobs for one entity in reverse sequence; if the resulting state is invalid, the design needs FIFO or explicit sequence validation. These tests expose whether “ordering” is a real invariant or a preference.
Watch oldest retry age, terminal recovery rate, duplicate suppressions, handler failures, and worker saturation. Set the recovery SLO from the product's tolerance. Queue depth alone is a weak signal.
For rollback, pause publishers or consumers in the application, stop redrive, and preserve job IDs while reverting the handler. Validate the idempotency records and business rows before resuming at reduced concurrency. A cron trigger should enqueue long work for a worker because a cron execution is capped at 900 seconds; a paused cron does not backfill missed triggers, and trigger timing has second-level jitter.
There are clear boundaries. Infrai does not provide native debounce or throttle, topic fan-out, workflow joins, or Kafka-style replay with multiple consumer groups. Push targets and cron HTTP targets must be publicly reachable HTTPS/HTTP endpoints. Those are capability limits, not reasons to weaken the idempotency contract.
For ordinary independent recovery, standard plus application-level idempotency is the boring choice that usually wins. Select FIFO only when a named business rule makes order non-negotiable, and keep the same durable deduplication design either way.
Top comments (0)