HTTP Calls Inside Transactions
Executing HTTP requests to external APIs (payment gateways, CRMs, search indexes) inside an ActiveRecord::Base.transaction block, holding the database connection and row locks open for the duration of the remote call.
Before / After
def checkout(order)
ActiveRecord::Base.transaction do
order.update!(status: 'processing')
# Stripe is slow today: 8 seconds.
# Transaction holds locks + pool connection.
response = Stripe::Charge.create(
amount: order.total_cents,
source: order.payment_token
)
order.update!(
status: 'paid',
stripe_id: response.id
)
end
end
# Pool exhausted under load,
# every request times out. def checkout(order)
ActiveRecord::Base.transaction do
order.update!(status: 'awaiting_payment')
end
# Side effect moved outside the transaction,
# idempotent, retryable.
ProcessPaymentJob.perform_later(order.id)
end
class ProcessPaymentJob < ApplicationJob
def perform(order_id)
order = Order.find(order_id)
return if order.paid?
response = Stripe::Charge.create(
amount: order.total_cents,
idempotency_key: "order-#{order.id}",
source: order.payment_token
)
order.update!(status: 'paid', stripe_id: response.id)
end
end Why this hurts
The ActiveRecord connection stays checked out of the pool for the entire body of the transaction block. A pool configured for RAILS_MAX_THREADS = 5 supports at most five concurrent transactions per process. When Stripe takes 8 seconds, every pending checkout holds its slot for 8 seconds, and five concurrent checkouts consume the whole pool. The sixth request blocks waiting for a connection and eventually raises ActiveRecord::ConnectionTimeoutError, which unrelated endpoints experience as sporadic 500s because they share the same pool.
The row locks acquired during order.update! persist until the transaction commits. Any other process trying to touch the same order (admin tool, reconciliation job, webhook handler) waits behind the lock. PostgreSQL’s lock_timeout eventually fires, but by then the damage propagates: support staff cannot view the order, webhooks queue and retry, replicas fall behind on WAL replay because the originating transaction is still open on the primary.
The transactional semantics are wrong in a subtle way. If the HTTP call succeeds but the final update! fails (database hiccup, validation change deployed mid-transaction), the rollback leaves the system with a charged card and no local record of payment. The inverse scenario is symmetrically bad: the transaction commits locally but the HTTP call timed out without response, so the customer might or might not have been charged. Neither the database nor the external service has the authoritative state.
The correct structure splits the transaction boundary precisely: persistent state changes go in the transaction, external effects go after commit via a retryable job with an idempotency key. The job either succeeds (and advances status), fails in a recoverable way (and retries), or fails permanently (and alerts operators for manual intervention).
Get Expert Help
Inheriting a legacy Rails codebase with this problem? Request a Technical Debt Audit.