Ruby on Rails

Retrying a timed-out payment request in Rails

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.

This article examines one narrow operation, creating a Stripe PaymentIntent. The companion experiment 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.

Give each payment operation a stable identity

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.

For the selected POST /v1/payment_intents contract, API version 2025-03-31.basil, Stripe documents that an idempotency key replays the saved response, including a saved 500. 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.

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.

Claim work without holding a transaction across the network

Two workers can load the same prepared payment. The corrected fixture uses a conditional database update to move an eligible operation into sending. Only the worker that changes one row sends the request. The other reports that delivery is already in progress.

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.

This design has an explicit limitation. If the winning process dies while the row says sending, 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.

Keep uncertain results visible

The fixture distinguishes completion from uncertainty. A dropped response leaves the operation available for a retry using its original identity. A replayed 500 remains unresolved. It does not cause the application to rotate the key until something succeeds.

Injected condition Result in the corrected fixture
Response lost after remote acceptance Retry records the existing remote object
Two overlapping workers One worker sends; the other observes an active claim
Stored request body changed Replay is blocked before a provider call
Replay after simulated key pruning Operation moves to reconciliation
Provider repeats a saved error Uncertainty remains visible

The example stops automatic retries after 23 hours from its first attempt. That conservative interval is an application policy, not Stripe’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.

A database commit and a queued job are separate checks

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 ready, from which the test explicitly redispatches it.

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.

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 Rails integration engagement can make those decisions and their acceptance tests part of the handover.

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.

Download the experiment

Download the runnable Rails examples and evidence summary (ZIP, 36 KB). This article’s example is in q01/; the shared setup and reproduction instructions are in README.md. The package contains eight synthetic examples, checked on 20 September 2026. The recorded runtime, provider fakes and limits are documented inside.

Leave a Comment

Your email address will not be published. Required fields are marked *