Rails Idempotency Keys: Veilige API-retries, Stripe-stijl, voor Betalingen en Webhooks
Rails idempotency keys: maak POST-endpoints veilig te retryen. Stripe-stijl middleware, Postgres-opslag, TTL, race-condition-proof patronen voor betalingen.
Een klant leverde vorig voorjaar een mobiele app die klanten dubbel afrekende op wankele luchthaven-wifi. Niet af en toe — betrouwbaar, elke keer als het netwerk hikte tussen de tik op “Betaal” en de 201 Created van de Rails-API die de telefoon moest bereiken. De app retried, de API maakte vrolijk een tweede Charge-rij aan, Stripe autoriseerde vrolijk een tweede capture, en support kreeg twintig dubbele-refund-tickets per week. De fix was één HTTP-header, een kleine Postgres-tabel en zo’n tachtig regels Ruby. De naam voor die header, en het patroon erachter, is Rails idempotency keys.
Na negentien jaar Rails-applicaties bouwen heb ik teams dit wiel drie of vier keer per stuk zien heruitvinden, meestal slecht, meestal na hun eerste dubbele-afrekening-incident. Deze post is de exacte Rails idempotency keys-implementatie die ik installeer: hoe je het headercontract ontwerpt, hoe je keys opslaat in Postgres zonder race conditions, hoe je exact hetzelfde antwoord byte-voor-byte replayed bij een retry, hoe lang je keys bewaart en de specifieke bugs die je bijten als je het verkeerd doet.
Wat Rails Idempotency Keys Echt Garanderen
Een idempotency key is een door de client gegenereerde identifier die een caller meestuurt met een niet-idempotent request — meestal POST — zodat de server een retry van dezelfde operatie kan herkennen en het originele antwoord teruggeeft in plaats van het werk twee keer uit te voeren. Stripe populariseerde het patroon met de Idempotency-Key-header, en het is de de-facto standaard geworden voor elke API waar dubbele uitvoering geld kost, een fysiek pakket verstuurt of een neveneffect triggert dat de client niet makkelijk kan terugdraaien.
Het contract, precies gesteld, is dit: voor een gegeven (api_key, request_path, idempotency_key)-triple binnen een bepaalde bewaartermijn MOET de server exact dezelfde HTTP-statuscode, headers en body teruggeven als de eerste keer — ongeacht of de payload van het tweede request identiek is, verschilt, gelijktijdig binnenkomt of dagen later arriveert. Dat maakt Rails idempotency keys echt veilig om te retryen: de caller kan het endpoint hameren totdat het een 2xx-antwoord krijgt en hoeft zich nooit zorgen te maken dat de server het onderliggende werk meer dan één keer doet.
Twee dingen die dit NIET geeft en die ik teams constant moet herinneren. Ten eerste beschermt het niet tegen verschillende clients die echt verschillende requests doen die logisch duplicaat blijken (“twee gebruikers klikken Buy vanuit dezelfde gedeelde login binnen dezelfde seconde”) — dat is een business-uniqueness-probleem, geen idempotency-probleem, en dat los je op met een unieke index. Ten tweede dedupliceert het niet over payload-wijzigingen als je besluit mismatching payloads te weigeren; de caller die een POST /charges retryed met een ander amount onder dezelfde key heeft een duidelijke error nodig, geen stille last-write-wins. Ik toon beide.
Het HTTP-Contract: Header, Scope en Antwoordvorm
Kies de headernaam die Stripe koos en wees niet creatief. Elke serieuze API-SDK — die van hen, Square, PayPal, GitHub — verwacht Idempotency-Key, en elke client-library retry-helper schrijft hem automatisch. Gebruik iets anders en je dwingt je klanten hand-rolled retry-logica te bouwen.
# De client stuurt:
# POST /api/v1/charges
# Idempotency-Key: 8b3f4c19-1e6e-4c2c-9c1b-4a5c9c9b3f1e
# Content-Type: application/json
#
# { "amount": 4999, "currency": "eur", "customer_id": "cus_123" }
#
# Bij het EERSTE request voeren we de charge uit en slaan het antwoord op.
# Bij elke RETRY met dezelfde key replayen we het opgeslagen antwoord.
Scope keys per API-credential, niet globaal. Als tenant A key abc genereert en tenant B onafhankelijk key abc genereert, mag het tweede request het gecachete antwoord van het eerste niet zien — dat is een data-leak-bug die op je wacht. De primary key van je idempotency-tabel is altijd de composite (api_key_id, key), nooit alleen key.
Beperk het keyformaat bij ingress: 8–255 tekens, printbare ASCII, en weiger alles wat niet matcht. UUIDv4 is de logische default en wat elke SDK genereert. Een ontbrekende Idempotency-Key op een niet-idempotent endpoint is een per-API-keuze; mijn default is om hem verplicht te maken op elk endpoint dat geld uitgeeft, een e-mail verstuurt of een externe resource aanmaakt, en te weigeren met 400 Bad Request als hij ontbreekt. Optioneel-key-endpoints zijn een bron van “waarom werd dit gereplayed?”-verwarring — kies één spoor.
De Postgres-Tabel Die Het Ondersteunt
Het opslagmodel is waar teams creatief en fout worden. Ik heb Redis-only-implementaties gezien die keys verloren tijdens een failover en een batch betalingen opnieuw uitvoerden, en ik heb keys op het request-model zelf zien staan zonder UNIQUE-index en met een behulpzame find_or_create_by-race. Doe het in Postgres met een echte unieke constraint, en gebruik een aparte tabel zodat je onafhankelijk kunt prunen.
# db/migrate/20260820120000_create_idempotency_keys.rb
class CreateIdempotencyKeys < ActiveRecord::Migration[8.0]
def change
create_table :idempotency_keys do |t|
t.references :api_credential, null: false, foreign_key: true
t.string :key, null: false, limit: 255
t.string :request_path, null: false, limit: 500
t.string :request_method, null: false, limit: 10
t.string :request_fingerprint, null: false, limit: 64
t.integer :response_status
t.jsonb :response_headers
t.text :response_body
t.string :status, null: false, default: "in_progress", limit: 20
t.datetime :locked_at
t.datetime :expires_at, null: false
t.timestamps
end
add_index :idempotency_keys,
[:api_credential_id, :key],
unique: true,
name: "idx_idem_keys_scoped_unique"
add_index :idempotency_keys, :expires_at
end
end
request_fingerprint is een SHA-256 van de canonieke request-body (of de gesorteerde querystring voor GETs die je besluit te beschermen). Hiermee kun je de “zelfde key, andere payload”-case expliciet detecteren en een 409 Conflict teruggeven in plaats van stilletjes een antwoord voor een ander request te replayen. status onderscheidt in_progress van completed — een caller die retryed terwijl het eerste request nog draait mag niet racen en het werk twee keer uitvoeren. Gerelateerd: mijn post over Postgres advisory locks behandelt dezelfde klasse race conditions vanuit de cron-kant.
Middleware Die Het Hele Antwoord Wraped
De schoonste plek om Rails idempotency keys af te dwingen is een Rack-middleware die na authenticatie en vóór je controllers zit. Op die manier erft elk POST-endpoint het gedrag gratis en kun je er niet één vergeten aan te melden.
# app/middleware/idempotency_middleware.rb
class IdempotencyMiddleware
IDEMPOTENT_METHODS = %w[POST PATCH].freeze
PROTECTED_PATHS = %r{\A/api/v1/(charges|refunds|subscriptions|transfers)}
def initialize(app)
@app = app
end
def call(env)
req = Rack::Request.new(env)
return @app.call(env) unless applicable?(req)
key = req.get_header("HTTP_IDEMPOTENCY_KEY")
return json_error(400, "Idempotency-Key header required") if key.blank?
return json_error(400, "Invalid Idempotency-Key format") unless valid_key?(key)
credential = env["api.credential"] # gezet door upstream auth middleware
return json_error(401, "Unauthorized") unless credential
IdempotencyHandler.new(
app: @app, env: env, req: req, key: key, credential: credential
).call
end
private
def applicable?(req)
IDEMPOTENT_METHODS.include?(req.request_method) && PROTECTED_PATHS.match?(req.path)
end
def valid_key?(key)
key.is_a?(String) && key.bytesize.between?(8, 255) && key.match?(/\A[[:print:]]+\z/)
end
def json_error(status, message)
[status, { "Content-Type" => "application/json" }, [{ error: message }.to_json]]
end
end
Hang hem in config/application.rb na je API-authenticatie-middleware. Als je die nog niet gebouwd hebt, behandelt mijn Rails API authenticatie-post de JWT- en API-key-patronen die ik gebruik voor de upstream credential-resolutie waar deze middleware van afhangt.
De Handler: Acquire, Execute, Cache, Replay
De interessante logica leeft in IdempotencyHandler. Hij doet vier dingen in een specifieke volgorde: probeer een in_progress-rij te inserten (de race winnen), voer de gewrapte app uit, persisteer het antwoord, en bij elke retry replay het opgeslagen antwoord. De truc is dat het inserten van de rij gebeurt binnen een transactie met ON CONFLICT-semantiek zodat exact één request wint en elke andere retry de winnende rij ziet.
# app/services/idempotency_handler.rb
class IdempotencyHandler
LOCK_TIMEOUT = 30.seconds
RETENTION = 24.hours
def initialize(app:, env:, req:, key:, credential:)
@app, @env, @req, @key, @credential = app, env, req, key, credential
end
def call
fingerprint = compute_fingerprint
record = acquire_or_find(fingerprint)
if record.status == "completed"
return replay(record) if record.request_fingerprint == fingerprint
return conflict_response
end
if record.status == "in_progress" && !stale?(record)
return in_progress_response
end
execute_and_store(record, fingerprint)
rescue ActiveRecord::RecordNotUnique
retry
end
private
def acquire_or_find(fingerprint)
IdempotencyKey.transaction do
IdempotencyKey.create!(
api_credential_id: @credential.id,
key: @key,
request_path: @req.path,
request_method: @req.request_method,
request_fingerprint: fingerprint,
status: "in_progress",
locked_at: Time.current,
expires_at: RETENTION.from_now
)
end
rescue ActiveRecord::RecordNotUnique
IdempotencyKey.find_by!(api_credential_id: @credential.id, key: @key)
end
def execute_and_store(record, fingerprint)
status, headers, body = @app.call(@env)
body_str = body.respond_to?(:each) ? body.each.to_a.join : body.to_s
record.update!(
response_status: status,
response_headers: cacheable_headers(headers),
response_body: body_str,
request_fingerprint: fingerprint,
status: "completed",
locked_at: nil
)
[status, headers, [body_str]]
end
def replay(record)
[record.response_status, record.response_headers.merge("Idempotent-Replay" => "true"), [record.response_body]]
end
def conflict_response
body = { error: "Idempotency-Key reused with a different request body" }.to_json
[409, { "Content-Type" => "application/json" }, [body]]
end
def in_progress_response
body = { error: "A request with this Idempotency-Key is still in progress" }.to_json
[409, { "Content-Type" => "application/json", "Retry-After" => "2" }, [body]]
end
def stale?(record)
record.locked_at.present? && record.locked_at < LOCK_TIMEOUT.ago
end
def compute_fingerprint
body = @req.body.read.to_s
@req.body.rewind
Digest::SHA256.hexdigest("#{@req.request_method}:#{@req.path}:#{body}")
end
def cacheable_headers(headers)
headers.reject { |k, _| %w[Set-Cookie Transfer-Encoding].include?(k) }
end
end
Vijf details verdienen daar hun plek. De ActiveRecord::RecordNotUnique-rescue plus retry-dan-find-patroon is hoe je twee gelijktijdige eerste requests afhandelt zonder een row-level lock. De Idempotent-Replay-header op de replay maakt het debugbaar in productie — je kunt er in je logs op grepen. Het strippen van Set-Cookie voorkomt dat je de sessiecookie van de eerste caller lekt naar een retrier die technisch dezelfde API-credential is maar een andere browser. De stale?-check ontklemt dode in_progress-rijen als het originele request de worker liet crashen voordat het klaar kon zijn. En de request-body-rewind is belangrijk omdat Rack-streams en once-read bodies leeg zijn bij de tweede read — ik heb precies deze bug om 2 uur ‘s nachts meer gedebugd dan ik wil toegeven.
De Race Condition Die Iedereen Vergeet
Het bugpatroon dat ik het meest zie is dit: developer schrijft find_or_create_by(key: key) in een controller, test het lokaal en shipt. Vervolgens krijgt productie een concurrente double-tap — mobiele app retryed op 2000ms terwijl het eerste request op 2001ms landt — en beide requests voeren het onderliggende werk uit omdat find_or_create_by een SELECT gevolgd door een INSERT is zonder unieke-index-afdwinging ertussen.
Het middleware-patroon hierboven ontwijkt dit omdat de create! met de unieke index atomair faalt voor de verliezer, en de rescue-clause van de verliezer de rij van de winnaar vindt. Maar als je in de verleiding komt een shortcut te nemen, lees dan de daadwerkelijk gegenereerde SQL en overtuig jezelf dat de race gesloten is. INSERT ... ON CONFLICT DO NOTHING RETURNING * met een follow-up SELECT voor de null-return case is een ander veilig patroon; find_or_create_by zonder de transactiedans is dat niet.
Voor het specifiek gevaarlijke geval van langlopend werk — een Stripe-charge die 800ms nodig heeft om te autoriseren — koopt de in_progress-state je correctheid. Een retry binnen het venster geeft 409 terug met Retry-After: 2, de client wacht en retryed, en tegen die tijd is het originele antwoord gecachet en wordt het gereplayed. Dit is wat Stripe’s eigen API doet: hun docs zijn expliciet dat een retry terwijl de eerste nog draait een specifieke error teruggeeft, en hun SDK’s handelen dat af door back-off en retry met dezelfde key.
Payload-Wijzigingsdetectie en Waarom Dat Ertoe Doet
Als een client dezelfde Idempotency-Key stuurt met een andere body, heb je een keuze: replay het originele antwoord (Stripe’s gedrag voor hun Idempotency-Key) of wijs af met 409 Conflict. Mijn default is afwijzen, omdat stille replay client-bugs maskeert die anders in QA zouden opduiken — stel je een developer voor die denkt een €100-refund te maken en altijd een €50-refund terug krijgt omdat ze de key vergeten te roteren van een eerdere test.
De request_fingerprint-kolom hierboven maakt de check goedkoop. Wanneer de middleware een bestaande completed-rij vindt, vergelijkt hij de fingerprint van het huidige request met de opgeslagen fingerprint en geeft 409 terug bij mismatch met een body die precies uitlegt waarom. Dit is uitzonderlijk nuttig tijdens integratieontwikkeling — elke developer raakt dit minstens één keer, en de foutmelding vertelt ze “je hergebruikte een key” in plaats van “waarom werkte mijn update niet?”
Fingerprint over de canonieke body, niet de ruwe. {"a":1,"b":2} en {"b":2,"a":1} moeten hetzelfde hashen, dus sorteer keys voor het hashen of spreek met je callers een canonieke JSON-vorm af. Voor de meeste SDK-gedreven API’s komt het verschil niet ter sprake, maar het bijt je de eerste keer als een klant een eigen client in Go schrijft.
Bewaartermijn, Prunen en Kosten
Idempotency keys zijn geen gratis opslag — elke POST naar een beschermd endpoint kost één rij plus de grootte van het antwoord. Mijn default-bewaartermijn is 24 uur, wat comfortabel elk realistisch retry-scenario dekt (mobiele client op de achtergrond, network partition, deploy-gedreven timeout) zonder jarenlang dode data te bewaren.
Prune met een geplande job:
# app/jobs/prune_idempotency_keys_job.rb
class PruneIdempotencyKeysJob < ApplicationJob
queue_as :low_priority
def perform
IdempotencyKey.where("expires_at < ?", Time.current).in_batches(of: 5_000).delete_all
end
end
Plan hem in per uur. Als je op Rails 8’s Solid Queue zit, heeft mijn post over Solid Queue recurring jobs de cron-syntax; op Sidekiq gebruik je sidekiq-cron. Grofweg: een service die 1 miljoen beschermde POSTs per dag doet met ~2 KB gemiddelde response body accumuleert ~2 GB per dag aan idempotency-rijen voordat je pruned, ~2 GB steady-state bij 24-uur bewaartermijn. Dat is een afrondingsfout op elke echte Postgres, maar goed om te weten voordat je het aanzet.
Compressie is optioneel en zelden de moeite waard — antwoorden voor financiële API-calls zijn typisch kleine JSON-objecten, en de JSONB-overhead om response_headers gestructureerd op te slaan is al efficiënt. Als je antwoorden groot zijn (gepagineerde lijsten, embedded documents), sla een URL naar blob-storage op in plaats van megabytes in Postgres in te lijnen.
Idempotency Keys Testen Zonder Flakes
Twee categorieën tests zijn de moeite waard om te schrijven en ik zie teams beide overslaan. Ten eerste de happy-path replay: zelfde key, zelfde body, verifieer het zelfde antwoord en de Idempotent-Replay: true-header. Ten tweede de race: schiet twee concurrente requests af met dezelfde key en bewijs dat exact één uitgevoerd is.
# spec/requests/idempotency_spec.rb
require "rails_helper"
RSpec.describe "Idempotency keys", type: :request do
let(:credential) { create(:api_credential) }
let(:headers) do
{
"Authorization" => "Bearer #{credential.token}",
"Idempotency-Key" => SecureRandom.uuid,
"Content-Type" => "application/json"
}
end
let(:payload) { { amount: 4999, currency: "eur", customer_id: "cus_test" }.to_json }
it "replayed het eerste antwoord bij retry" do
post "/api/v1/charges", params: payload, headers: headers
first_status, first_body = response.status, response.body
expect(first_status).to eq(201)
post "/api/v1/charges", params: payload, headers: headers
expect(response.status).to eq(first_status)
expect(response.body).to eq(first_body)
expect(response.headers["Idempotent-Replay"]).to eq("true")
expect(Charge.count).to eq(1)
end
it "weigert dezelfde key met een andere payload" do
post "/api/v1/charges", params: payload, headers: headers
post "/api/v1/charges", params: { amount: 9999, currency: "eur", customer_id: "cus_test" }.to_json, headers: headers
expect(response.status).to eq(409)
expect(Charge.count).to eq(1)
end
it "voert het werk exact één keer uit bij concurrente requests" do
threads = 5.times.map do
Thread.new { post "/api/v1/charges", params: payload, headers: headers }
end
threads.each(&:join)
expect(Charge.count).to eq(1)
end
end
De concurrente test is degene die implementatiebugs vangt — als je de unieke index dropt, of create! vervangt door find_or_create_by, dan flaked deze test bij de eerste CI-run en blijft flaky. Voor diepere end-to-end verificatie zie Rails system tests met Capybara, maar voor pure API-idempotency is het request-spec-niveau precies de juiste hoogte.
Observability: Log de Replays
Elke replay is óf een goedaardige client-retry óf een bug in je client-SDK, en je kunt niet zeggen welk zonder te loggen. Emit een gestructureerde logregel bij elke replay en elke 409, en zet beide op een Grafana-paneel naast je API-error rates.
# In IdempotencyHandler#replay
Rails.logger.info(
event: "idempotency.replay",
key: @key,
credential_id: @credential.id,
path: @req.path,
original_status: record.response_status,
age_seconds: (Time.current - record.created_at).to_i
)
Het age_seconds-veld is het interessante. Retries binnen 5 seconden zijn network-hikjes; retries op 30+ seconden zijn meestal een client-SDK met een te agressieve retry-loop of een slecht geconfigureerde circuit breaker. Beide zijn het weten waard — de een wil een client-fix, de ander wil een alert.
Veelvoorkomende Fouten Die Ik Nog Steeds Zie in Productie
Een korte lijst van failure modes die ik meer dan één keer heb gedebugd:
- Geen unieke index op
(api_credential_id, key).find_or_create_byziet er correct uit en is dat niet; concurrente requests voeren beide het onderliggende werk uit. Laat Postgres altijd uniciteit afdwingen. - Verkeerde headers cachen.
Set-Cookie,Transfer-Encodingen elkeAuthorization: Bearer-echo moet gestript worden uit het opgeslagen antwoord of je lekt state tussen callers. - Geen
in_progress-afhandeling. Een retry die binnenkomt terwijl de eerste nog draait racet en voert het werk twee keer uit. Sla status op en geef409 Retry-Afterterug terwijl de eerste nog in flight is. - Keys opslaan in Redis zonder persistentie. Een Redis-failover tussen de write en de retry verliest de key en voert opnieuw uit. Postgres is de juiste plek voor alles dat geldbewegingen bewaakt.
- Globale key-scope in plaats van per-credential. Client A’s key botst met Client B’s key en de een krijgt het antwoord van de ander. Composite index, altijd.
- De request-body niet rewinden. Rack-input-streams zijn single-read; fingerprint-berekening leegt de body en je controller ziet een lege payload.
req.body.rewindna elke read.
Veelgestelde Vragen
Hoe verschilt een Rails idempotency key van een database-unique-constraint?
Ze lossen verschillende lagen op. Een unique constraint (bijvoorbeeld op order.external_reference) voorkomt dubbele rijen voor een business-identifier die de client al zelf genereerde. Rails idempotency keys beschermen de HTTP-grens tegen transport-level-retries — hetzelfde request twee keer verstuurd door een netwerklaag die niet weet dat de eerste succesvol was. Je wilt vaak beide: de idempotency key stopt het tweede POST überhaupt van uitvoeren, en de unieke index is de riem-en-bretels-backup mocht je ooit per ongeluk de middleware uitschakelen.
Moet ik Rails idempotency keys op GET-endpoints zetten?
Nee. GET is per definitie al idempotent, en een key toevoegen zou alleen opslagkosten toevoegen zonder correctheidsvoordeel. Reserveer Rails idempotency keys voor POST, PATCH en soms DELETE op endpoints die neveneffecten triggeren. Als een GET echt duur is (LLM-calls, rapportgeneratie), grijp dan naar HTTP-caching met ETag — zie Rails HTTP caching — niet naar idempotency keys.
Kan ik Redis gebruiken in plaats van Postgres voor Rails idempotency keys?
Kan, maar ik zou het niet doen voor iets dat geldbeweging bewaakt. Redis mist Postgres’ duurzaamheidsgaranties tijdens failover; een goed-getimede primary swap kan keys verliezen die geschreven en bevestigd waren, wat re-uitvoering van een betaling veroorzaakt. Redis is prima voor kortlevende deduplicatie van laag-risico-endpoints (analytics-ingest, telemetrie) waar de kost van een incidenteel duplicaat een afrondingsfout is.
Welk bewaarvenster moet ik gebruiken voor Rails idempotency keys?
24 uur is mijn default en dekt in principe elk realistisch retry-scenario zonder de opslag te doen exploderen. Stripe gebruikt 24 uur. Korter (bijvoorbeeld 15 minuten) faalt in het beschermen tegen mobiele-app-op-de-achtergrond-voor-een-tijdje-retries; langer (7 dagen) doet de opslag exploderen zonder iets echts te beschermen — niemand retryed een POST zeven dagen later denkend dat hij misschien mislukt is. Begin bij 24u, tune met data.
Hulp nodig bij het ontwerpen van een API die klanten daadwerkelijk veilig kunnen retryen, of bij het auditen van een bestaande op stille dubbele-uitvoer-bugs? TTB Software bouwt al negentien jaar productie-Rails-systemen, en idempotency keys staan op de checklist voor elk betalings- of webhook-genererend endpoint dat we opleveren.
Related Articles
Rails Rack::Attack: Rate Limiting, IP-Throttling en Misbruik Blokkeren in Productie
Rails Rack::Attack gids: rate limiting, IP-throttling, fail2ban en misbruik blokkeren in productie. Werkende config v...
Rails pgvector: Semantische Zoek en RAG met PostgreSQL voor LLM-Applicaties
Rails pgvector gids: bouw semantische zoek en RAG op PostgreSQL met embeddings, HNSW-indexen en hybride retrieval. Pr...
Rails API-authenticatie: JWT, Sessiecookies en API-sleutels — Wanneer Gebruik Je Wat
Rails API-authenticatie in 2026: JWT, sessiecookies en API-sleutels vergeleken — beveiligingsafwegingen, Rails 8-code...