{"id":14913,"date":"2026-09-22T17:30:00","date_gmt":"2026-09-22T12:00:00","guid":{"rendered":"https:\/\/www.allerin.com\/blog\/?p=14913"},"modified":"2026-09-20T19:36:16","modified_gmt":"2026-09-20T14:06:16","slug":"rails-payment-retry-idempotency","status":"publish","type":"post","link":"https:\/\/www.allerin.com\/blog\/rails-payment-retry-idempotency\/","title":{"rendered":"Retrying a timed-out payment request in Rails"},"content":{"rendered":"<style>.allerin-rails-question{max-width:100%;overflow-wrap:anywhere}.allerin-rails-question h2{scroll-margin-top:125px}.allerin-rails-question nav{border-left:3px solid #17665b;padding:10px 18px;margin:24px 0}.allerin-rails-question nav ol{padding-left:22px}.allerin-rails-question nav a{display:inline-block}.allerin-rails-question code{white-space:normal;overflow-wrap:anywhere}.allerin-rails-question pre{max-width:100%;overflow:auto}.allerin-rails-question pre code{white-space:pre;overflow-wrap:normal}.allerin-rails-question .table-wrap{max-width:100%;overflow-x:auto;margin:22px 0}.allerin-rails-question table{width:100%;border-collapse:collapse;line-height:1.5}.allerin-rails-question th,.allerin-rails-question td{border:1px solid #d1d9d5;padding:10px;text-align:left;vertical-align:top}.allerin-rails-question th{background:#edf2ef}.allerin-rails-question[data-question=\"q05\"] th:nth-child(-n+2),.allerin-rails-question[data-question=\"q05\"] td:nth-child(-n+2){white-space:nowrap;overflow-wrap:normal}.allerin-rails-question .experiment-download{margin-top:28px;border-top:1px solid #d1d9d5;padding-top:18px;font-size:0.93em}<\/style>\n<div class=\"allerin-rails-question\" data-question=\"q01\">\n<nav aria-label=\"In this article\"><strong>In this article<\/strong><\/p>\n<ol>\n<li><a href=\"#give-the-operation-an-identity-before-sending-it\">Give each payment operation a stable identity<\/a><\/li>\n<li><a href=\"#claim-work-without-holding-a-transaction-across-the-network\">Claim work without holding a transaction across the network<\/a><\/li>\n<li><a href=\"#keep-uncertain-results-visible\">Keep uncertain results visible<\/a><\/li>\n<li><a href=\"#a-database-commit-and-a-queued-job-are-separate-checks\">A database commit and a queued job are separate checks<\/a><\/li>\n<\/ol>\n<\/nav>\n<p>A payment request times out after leaving your Rails process. The provider might have rejected it, or accepted it and lost the response on the way back. Starting another request with a new identity can create a second operation. Recording the original operation before sending it gives the retry something dependable to refer to.<\/p>\n<p>This article examines one narrow operation, creating a Stripe PaymentIntent. The <a href=\"#download-experiments\">companion experiment<\/a> uses Active Record and a local fake provider. It does not charge a card, execute Stripe requests or test a production queue. A PaymentIntent ID in the example represents a created object, not evidence that money was collected.<\/p>\n<h2 id=\"give-the-operation-an-identity-before-sending-it\">Give each payment operation a stable identity<\/h2>\n<p>The fixture stores a business reference, the request body, its fingerprint, an idempotency key and a delivery state in SQLite. A unique database index prevents the same business reference from being prepared twice. The key belongs to that operation rather than to an individual job attempt.<\/p>\n<p>For the selected <code>POST \/v1\/payment_intents<\/code> contract, API version <code>2025-03-31.basil<\/code>, <a href=\"https:\/\/docs.stripe.com\/api\/idempotent_requests\" target=\"_blank\" rel=\"noopener\">Stripe documents<\/a> that an idempotency key replays the saved response, including a saved <code>500<\/code>. Reusing a key with different parameters is rejected. Keys can be removed after they are at least 24 hours old; that is a retention boundary to plan around, not a promise of permanent deduplication.<\/p>\n<p>The fake implements just the response-replay behavior needed for these tests. In its unsafe baseline, the first call creates an object and drops the response. Retrying with a new key creates a second object. The test deliberately verifies that failure. With the persisted key, the second call returns the first object, and the local bookkeeping table contains one effect.<\/p>\n<h2 id=\"claim-work-without-holding-a-transaction-across-the-network\">Claim work without holding a transaction across the network<\/h2>\n<p>Two workers can load the same prepared payment. The corrected fixture uses a conditional database update to move an eligible operation into <code>sending<\/code>. Only the worker that changes one row sends the request. The other reports that delivery is already in progress.<\/p>\n<p>The concurrency test pauses the first worker after it claims the row. A second thread then attempts delivery before the first can finish. This creates a deterministic overlap instead of hoping a timing race occurs. One remote call and one local effect remain after both workers finish.<\/p>\n<p>This design has an explicit limitation. If the winning process dies while the row says <code>sending<\/code>, the example has no lease expiry or automatic reclaim procedure. Production recovery needs a reviewed way to identify abandoned claims and reconcile their remote outcome. Simply clearing every old claim could reintroduce the uncertain-response problem.<\/p>\n<h2 id=\"keep-uncertain-results-visible\">Keep uncertain results visible<\/h2>\n<p>The fixture distinguishes completion from uncertainty. A dropped response leaves the operation available for a retry using its original identity. A replayed <code>500<\/code> remains unresolved. It does not cause the application to rotate the key until something succeeds.<\/p>\n<div class=\"table-wrap\" tabindex=\"0\" role=\"region\" aria-label=\"Experiment comparison table\">\n<table>\n<thead>\n<tr>\n<th>Injected condition<\/th>\n<th>Result in the corrected fixture<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>Response lost after remote acceptance<\/td>\n<td>Retry records the existing remote object<\/td>\n<\/tr>\n<tr>\n<td>Two overlapping workers<\/td>\n<td>One worker sends; the other observes an active claim<\/td>\n<\/tr>\n<tr>\n<td>Stored request body changed<\/td>\n<td>Replay is blocked before a provider call<\/td>\n<\/tr>\n<tr>\n<td>Replay after simulated key pruning<\/td>\n<td>Operation moves to reconciliation<\/td>\n<\/tr>\n<tr>\n<td>Provider repeats a saved error<\/td>\n<td>Uncertainty remains visible<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<\/div>\n<p>The example stops automatic retries after 23 hours from its first attempt. That conservative interval is an application policy, not Stripe&#8217;s expiry setting. Once the interval closes, an operator needs evidence about the previous outcome before authorizing another operation. The test simulates provider key pruning and verifies that the application makes no second call.<\/p>\n<h2 id=\"a-database-commit-and-a-queued-job-are-separate-checks\">A database commit and a queued job are separate checks<\/h2>\n<p>Two additional tests cover local preparation. Rolling back a newly prepared operation leaves no record and makes no provider call. A deliberately failing Active Job adapter leaves the already committed operation in <code>ready<\/code>, from which the test explicitly redispatches it.<\/p>\n<p>That result establishes a recovery starting point. It does not implement a production dispatcher, prove atomicity between an arbitrary queue and database, or handle every process-crash window. Those decisions belong in the delivery design for the actual application.<\/p>\n<p>The experiment was executed with Ruby 3.3.3, Active Record and Active Job 8.1.3.1, sqlite3 gem 2.9.5 and SQLite 3.53.2. Its local checks distinguish request identity, concurrent ownership and unresolved outcomes. Before applying the pattern, review the chosen endpoint, SDK retries, queue behavior and recovery ownership together. A bounded <a href=\"https:\/\/www.allerin.com\/services\/ruby-on-rails\">Rails integration engagement<\/a> can make those decisions and their acceptance tests part of the handover.<\/p>\n<p><em>This article and synthetic experiment were prepared with AI assistance. They are not an account of an Allerin production incident or human engineering sign-off.<\/em><\/p>\n<section class=\"experiment-download\" aria-labelledby=\"download-experiments\">\n<h2 id=\"download-experiments\">Download the experiment<\/h2>\n<p><a href=\"https:\/\/www.allerin.com\/blog\/wp-content\/uploads\/2026\/09\/allerin-rails-synthetic-experiments-2026-09-20.zip\">Download the runnable Rails examples and evidence summary (ZIP, 36 KB)<\/a>. This article\u2019s example is in <code>q01\/<\/code>; the shared setup and reproduction instructions are in <code>README.md<\/code>. The package contains eight synthetic examples, checked on 20 September 2026. The recorded runtime, provider fakes and limits are documented inside.<\/p>\n<\/section>\n<\/div>\n","protected":false},"excerpt":{"rendered":"<p>A persisted Rails experiment tests uncertain payment responses, concurrent retries, changed parameters and expired idempotency keys.<\/p>\n","protected":false},"author":2,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":"","_links_to":"","_links_to_target":""},"categories":[2037],"tags":[2077,2061,20],"class_list":["post-14913","post","type-post","status-publish","format-standard","hentry","category-ruby-on-rails","tag-rails-integrations","tag-rails-testing","tag-ruby-on-rails"],"_links":{"self":[{"href":"https:\/\/www.allerin.com\/blog\/wp-json\/wp\/v2\/posts\/14913","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.allerin.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.allerin.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.allerin.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/www.allerin.com\/blog\/wp-json\/wp\/v2\/comments?post=14913"}],"version-history":[{"count":4,"href":"https:\/\/www.allerin.com\/blog\/wp-json\/wp\/v2\/posts\/14913\/revisions"}],"predecessor-version":[{"id":14962,"href":"https:\/\/www.allerin.com\/blog\/wp-json\/wp\/v2\/posts\/14913\/revisions\/14962"}],"wp:attachment":[{"href":"https:\/\/www.allerin.com\/blog\/wp-json\/wp\/v2\/media?parent=14913"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.allerin.com\/blog\/wp-json\/wp\/v2\/categories?post=14913"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.allerin.com\/blog\/wp-json\/wp\/v2\/tags?post=14913"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}