Idempotent by Design: Making Your Sidekiq Jobs Safe to Retry
Sidekiq guarantees at-least-once delivery, not exactly-once. Any job that is not safe to run twice is a bug waiting for the right overlap.
Published February 5, 2023 · 4 min read
Sidekiq's contract is at-least-once delivery, not exactly-once. A job can run twice: a worker times out after finishing but before acknowledging, a deploy restarts a process mid-job, Redis fails over. Any job that isn't safe to run twice is a bug waiting for that overlap to happen.
The classic failure: double charging
# Not idempotent: runs twice, charges twice
class ChargeCardJob
include Sidekiq::Job
def perform(order_id)
order = Order.find(order_id)
PaymentGateway.charge(order.amount_cents, order.card_token)
order.update!(charged: true)
end
end
If the process dies after the gateway call succeeds but before the update commits, Sidekiq retries the job, and the gateway gets called a second time with no way to know it already ran.
An idempotency key closes the gap
Most payment gateways, and plenty of other external APIs, accept an idempotency key: pass the same key twice and the second call returns the first call's result instead of executing again. Generate that key from something stable about the job, not from a random value, or a retry will generate a new key and defeat the whole point.
class ChargeCardJob
include Sidekiq::Job
def perform(order_id)
order = Order.find(order_id)
return if order.charged?
PaymentGateway.charge(
order.amount_cents,
order.card_token,
idempotency_key: "charge-order-#{order.id}"
)
order.update!(charged: true)
end
end
Guard the check-then-act gap too
The early return on an already-charged order helps, but two overlapping runs of the same job can both pass that check before either one updates the record. A unique database constraint, or wrapping the read and the flag update in a locked transaction, closes that remaining window instead of relying on timing alone.
Idempotency isn't only for payments
Sending an email twice is a minor embarrassment; charging a card twice is a support ticket and a chargeback. But the same discipline, an operation-specific key, a check against prior execution, applies to any job with a side effect outside your own database: emails, webhooks fired to a partner, third-party API calls.
Related articles
The Outbox Pattern in Rails: How to Publish Events Without Losing Data
The gap between saving a record and publishing the message that announces it is where events quietly disappear. Here is how to close it.
Kafka or Sidekiq? Choosing the Right Async Tool for Your Ruby on Rails Backend
A practical decision path for when a Rails app genuinely needs a message broker, and when Sidekiq is doing everything you actually need.