Rails Timeouts: Statement, Rack, HTTP Client en Job Timeouts die Productiecascades Voorkomen
Rails timeouts goed geregeld: configureer statement_timeout, rack-timeout, HTTP client en background job limieten om cascadefouten in Rails te voorkomen.
De pager gaat om 14:12 op een woensdagmiddag. Een checkoutpagina loopt op time-outs. Het team kijkt naar de checkoutcode en vindt niets. Ze kijken naar Postgres en vinden één query op een products-tabel die al elf minuten draait. Ze kijken naar Puma en zien elke worker vasthangen op datzelfde request. Ze kijken naar Sidekiq en zien de retry-queue oplopen. Ze kijken naar de CDN en zien requests zich opstapelen in de proxy. Niets is kapot. Alles staat te wachten.
Ik ben in de afgelopen twee jaar precies deze storing vier keer binnengelopen. Andere bedrijven, andere producten, dezelfde vorm. Één trage plek, geen enkele timeout in de stack om het te stoppen, en de hele applicatie bevriest omdat niets bereid is om op te geven. Na negentien jaar Rails ben ik ervan overtuigd geraakt dat Rails timeouts geen nice-to-have zijn — het is de goedkoopste betrouwbaarheidsupgrade die je op een productiesysteem kunt toepassen, en niemand leert ze totdat de storing er is.
Deze post is wat ik op dag één installeer op elk serieus Rails-productiesysteem: statement timeouts in Postgres, request-timeouts op de Rack-laag, HTTP-timeouts per client, en per-job timeouts in de queue. Goed ingesteld lossen ze je bugs niet op — ze voorkomen dat één bug een storing wordt.
Waarom Rails Timeouts Niet Optioneel Zijn
Elke I/O-call in een Rails-proces is een gok. Je gokt dat de database binnen redelijke tijd antwoordt. Je gokt dat Stripe binnen een seconde terugkomt. Je gokt dat de browser van de klant de response leest voordat de connectie stukgaat. Negenennegentig van de honderd keer win je. De honderdste keer is de storing.
Zonder timeouts blijft die storing niet lokaal. Rails is standaard synchroon: een Puma-worker houdt een thread vast voor de volledige duur van een request. Als één databasequery elf minuten hangt, is die worker elf minuten onbeschikbaar. Als tien workers dezelfde trage query raken, is je hele applicatie onbeschikbaar. Dit bedoelen mensen met cascading failures: de oorspronkelijke fout is beperkt, maar het gebrek aan timeouts zet die om in een stampede.
Rails timeouts zijn de circuit breakers die die stampede stoppen. Elke timeout zegt: “als deze call niet binnen X terug is, geef op, geef de thread vrij, en laat iets anders draaien.” Meer niet. Het is niet fancy. Het is niet glamoureus. Het is het verschil tussen een 500 op één endpoint en een volledige site-outage.
De Vier Lagen van Rails Timeouts
Elk Rails-request raakt vier lagen waar een call kan hangen, en elke laag heeft zijn eigen timeout-mechanisme. Je hebt alle vier nodig, en ze moeten correct nesten.
- Postgres statement timeout — de database weigert één statement langer dan N seconden uit te voeren.
- Rack request timeout — de web-laag breekt een request af dat na N seconden niet klaar is.
- HTTP client timeout — elke uitgaande HTTP-call heeft connect-, read- en write-timeouts.
- Background job timeout — de queue kapt een job af die langer dan zijn toegewezen tijd draait.
De regel die ze aan elkaar knoopt is één zin: binnenste timeouts moeten korter zijn dan buitenste timeouts. Als je Rack-timeout 15 seconden is en je Postgres statement_timeout 30, dan kapt Rack het request af voordat Postgres het opmerkt. De query blijft doordraaien, de connectie wordt teruggegeven aan de pool terwijl hij nog bezig is, en het volgende ongelukkige request erft een vergiftigde connectie. Correct genest vangt elke laag zijn eigen fout op voordat de buitenste er iets van hoeft te doen.
Postgres statement_timeout in een Rails-app
De timeout met de grootste hefboom op de lijst. statement_timeout is een Postgres-instelling die elke statement afkapt die langer dan de ingestelde waarde draait. Het is per sessie, dus je kunt hem globaal zetten, per rol, per connectie, of per transactie. In Rails is de juiste default: per rol — één waarde voor de web-workers, een andere voor background jobs, en géén timeout voor de migratie-user.
Zet het op de rol in de database:
ALTER ROLE ttb_web SET statement_timeout = '5s';
ALTER ROLE ttb_worker SET statement_timeout = '30s';
ALTER ROLE ttb_migrate SET statement_timeout = 0;
Elke connectie die ttb_web maakt krijgt nu automatisch een limiet van 5 seconden op elke query. Hier begin ik altijd — vijf seconden is ruim voor een webrequest en vangt het negenennegentigste percentiel aan runaway queries op voordat ze een worker opvreten.
Voor het zeldzame endpoint dat echt langer nodig heeft — een rapport, een bulk export — override je het in de controller met een scoped transactie:
class ReportsController < ApplicationController
def annual_summary
ActiveRecord::Base.transaction do
ActiveRecord::Base.connection.execute("SET LOCAL statement_timeout = '60s'")
@report = AnnualSummary.new(current_account).build
end
render :annual_summary
end
end
SET LOCAL beperkt de wijziging tot de huidige transactie en draait automatisch terug bij commit of rollback, dus het volgende request op deze connectie krijgt nog steeds de default van 5 seconden. Dit is het enige patroon dat ik vertrouw voor eenmalige overrides. SET statement_timeout zonder LOCAL blijft plakken op de connectie voor de rest van zijn leven, en dat is precies de bug die je niet om 3 uur ‘s nachts wilt debuggen.
Je kunt ook lock_timeout op dezelfde rol zetten om snel te falen op lock-contention — de meeste Rails-apps willen dat op één of twee seconden, zodat een vastgelopen migratie niet elk volgend request kan blokkeren. Als je Rails Strong Migrations nog niet gebruikt, past dat hier goed bij: strong-migrations blokkeert de onveilige migratie in development, en lock_timeout beperkt de schade van de migraties die er tussendoor glippen.
rack-timeout voor Request-niveau Rails Timeouts
Postgres-timeouts vangen alleen databasewerk af. Als een Ruby-side infinite loop of een hangende HTTP-call in een controller afgaat, ziet Postgres er niets van en draait het request eeuwig door. De rack-timeout gem installeert zichzelf als middleware en gooit een exception op in de worker-thread zodra de totale requesttijd een drempel passeert.
Voeg het toe aan de Gemfile en configureer het in een initializer:
# Gemfile
gem "rack-timeout"
# config/initializers/rack_timeout.rb
Rack::Timeout.service_timeout = 15 # kap het request af na 15s
Rack::Timeout.wait_timeout = 30 # kap af als het > 30s wachtte om te starten
Rack::Timeout.service_past_wait = false # niet starten als we al te laat zijn
Drie instellingen, allemaal belangrijk. service_timeout is het harde plafond op requestafhandelingstijd — alles daarboven gooit Rack::Timeout::RequestTimeoutException. wait_timeout telt de tijd die het request in de Puma-queue stond voordat een worker het oppakte; als de queue 30 seconden diep is, heeft het request zijn nuttige leven al opgesoupeerd en heeft draaien geen zin meer. service_past_wait op false betekent “begin niet eens aan een request dat zijn wachttijd al overschreden heeft” — daarmee valt de load-shed-beslissing één hop eerder, voordat er ook maar Ruby-code draait.
service_timeout moet korter zijn dan de upstream-timeout van je load balancer. Als de ELB opgeeft bij 30s en Rails niet, geeft de ELB een 504 terug terwijl de Puma-worker de thread nog vasthoudt. Het volgende request retryt, raakt dezelfde trage code, en nu heb je twee hangende workers in plaats van één. Vijftien seconden binnen Rails en dertig seconden op de ELB is het patroon dat ik het vaakst gebruik.
De exception die rack-timeout gooit is een echte Ruby-exception, dus je errortracker ziet ‘m. Groepeer ze zorgvuldig — een Rack::Timeout::RequestTimeoutException is geen bug, het is een signaal dat een specifiek endpoint traag is. Route ze naar een Slack-kanaal en behandel ze als werk-queue.
HTTP Client Timeouts voor Elke Uitgaande Call
De meest voorkomende manier waarop ik Rails-apps in 2026 zie omvallen is niet een trage query — het is een trage externe API. LLM-providers, betaalproviders, geocoders, webhooks. Als zij traag worden, worden Rails-workers dat met hen mee, en elke standaard Ruby HTTP-client heeft een oneindige timeout tenzij je er één zet.
Twee defaults die je moet aanpassen voor je enige externe call doet. Eerst Net::HTTP:
require "net/http"
uri = URI("https://api.stripe.com/v1/charges")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.open_timeout = 2 # TCP + TLS handshake
http.read_timeout = 5 # bytes op de socket na connect
http.write_timeout = 5 # zeldzaam, relevant bij grote uploads
request = Net::HTTP::Get.new(uri)
http.request(request)
De defaults voor Net::HTTP zijn in de praktijk onbegrensd. Als Stripe stopt met antwoorden, blijft je Puma-worker op de socket zitten totdat het OS er iets van vindt — en dat kan minuten duren. open_timeout, read_timeout en write_timeout expliciet zetten is niet optioneel. Ik gebruik als startpunt twee seconden voor connect en vijf voor read voor alles dat op een gezonde publieke API draait. LLM-streamingresponses zijn de uitzondering: een gehost model kan legitiem dertig seconden nemen, dus daar is read_timeout langer, maar elke andere call hoort ruim onder de tien seconden te zitten.
Voor Faraday zit hetzelfde op de connectie:
connection = Faraday.new(url: "https://api.stripe.com") do |f|
f.request :retry, max: 2, interval: 0.2, exceptions: [Faraday::TimeoutError]
f.response :raise_error
f.options.open_timeout = 2
f.options.timeout = 5
end
Faraday’s timeout mapt naar de read-timeout van de onderliggende adapter. Als je Faraday met Net::HTTP als adapter gebruikt, krijg je alle defaults van Net::HTTP tenzij je ze override — wat een andere manier is om te zeggen “geen timeout” voor alles behalve de velden die Faraday expliciet doorgeeft. Zet ze.
Voor de OpenAI-, Anthropic- en andere LLM-gems: check de timeout-opties in de client-config — de meeste wrappen Faraday en exposen een request_timeout-argument op initialisatie. Accepteer de default niet; de vendor-libraries komen met waarden die gekalibreerd zijn voor mooi weer, niet voor het 3-uur-‘s-nachts-incident.
Wil je defense in depth bovenop dit alles, wrap externe calls dan in een circuit breaker. Circuitbox of Semian opent de circuit na N faalpogingen en begint calls direct af te wijzen in plaats van elke call te laten uitlopen. Timeouts alleen zorgen dat één call niet je worker blokkeert; een circuit breaker zorgt dat de honderdste call nooit gemaakt wordt zodra duidelijk is dat de dependency down is.
Background Job Timeouts (SolidQueue en Sidekiq)
Background jobs zijn de laag waar teams timeouts het vaakst helemaal vergeten, met de theorie dat “het niet erg is als een job even duurt.” Dat is het wel. Een job die eeuwig draait houdt een worker-slot eeuwig bezet, en als er genoeg zijn loopt de queue vol. Elke job heeft een wandkloklimiet nodig.
Voor SolidQueue in Rails 8 zet je dispatcher.polling_interval en het eigen Timeout.timeout-blok van de job. SolidQueue forceert géén buitenste timeout op een draaiende job — je regelt dat binnen de job:
class GenerateReportJob < ApplicationJob
queue_as :default
MAX_DURATION = 120.seconds
def perform(report_id)
Timeout.timeout(MAX_DURATION) do
Report.find(report_id).generate!
end
rescue Timeout::Error => e
Rails.error.report(e, context: { report_id: report_id })
Report.find(report_id).mark_timed_out!
raise # laat de retry-backoff van ActiveJob beslissen wat er nu gebeurt
end
end
Timeout.timeout heeft in sommige hoeken van de Ruby-community een slechte naam — het gooit over threads heen en kan file handles of connecties in rare staten achterlaten. Voor een job die zijn eigen Postgres-connectie bezit en verder niets, is het prima. Voor jobs die aan gedeelde externe state komen — file locks, distributed mutexes — kies je liever een expliciete deadline-check binnen de job en breek je netjes af op natuurlijke grenzen.
Sidekiq geeft je een per-job timeout via de sidekiq-timeout gem of sidekiq_options timeout: uit Sidekiq Enterprise. Voor de meeste projecten op de free tier: wrap de body in Timeout.timeout en klaar. Combineer dit met Rails ActiveJob retries met exponential backoff zodat een timed-out job bij de volgende poging vanzelf terugkomt zonder dat een mens er iets aan hoeft te doen.
Cascading Timeouts: Hoe de Getallen Passen
Dit is het concrete recept dat ik op een verse Rails-productie installeer. Elk getal nest netjes binnen het volgende.
- ELB / CloudFront upstream-timeout: 30s
rack-timeoutservice_timeout: 15srack-timeoutwait_timeout: 30s- Postgres
statement_timeoutvoorttb_web-rol: 5s - Postgres
lock_timeoutvoorttb_web-rol: 2s - HTTP client
open_timeout: 2s - HTTP client
read_timeout: 5s (LLM’s: 30s) - Background job wandklok-timeout: 120s (of het eigen expliciete plafond van de job)
- Puma worker-restart bij OOM (via
puma_worker_killerofPUMA_WORKER_MEMORY_LIMIT): 1 GB
Lees de lijst van buiten naar binnen. De ELB kapt het request af op 30s. Rails kapt het af op 15s. Postgres weigert één query op 5s. Elke HTTP-call binnen dat request heeft 5s om terug te komen. Als een van die timers afgaat, valt het request netjes met een error, keert de worker terug naar de pool, en draait het volgende request. Het cascade-stukje is dat de binnenste laag altijd eerst afgaat — de database geeft op vóór Rails, Rails geeft op vóór de ELB. Het schot breekt nooit helemaal door.
De tijden zelf zijn startwaarden, geen wet. Pas ze per endpoint aan als het moet — maar pas ze expliciet aan, in code, met een comment die uitlegt waarom. Het enige wat je niet mag doen is ze weghalen.
De 3-uur-‘s-nachts-test: Heb Je Ze Echt Ingesteld?
Elk Rails-project waar ik due diligence op doe krijgt in het eerste uur dezelfde drie checks. Als je ze zelf nog niet gedraaid hebt, doe het vóór de volgende deploy.
Check één. SSH naar een productiebak, bundle exec rails runner "puts ActiveRecord::Base.connection.execute(%q{SHOW statement_timeout}).first". Als het 0 zegt, heb je geen databasetimeout. Zet er vandaag één op de rol.
Check twee. grep -r "Rack::Timeout" config/ in je Rails-app. Als er niets terugkomt, installeer de gem, zet service_timeout op 15s, en deploy. Dit is een wijziging van vijf regels die elk van de vier teams uit de opening had gered.
Check drie. Open de code voor je meestgebruikte externe service — Stripe, Twilio, OpenAI, wat het ook is. Zoek op open_timeout en read_timeout. Als ze niet gezet zijn, zet ze. Twee seconden en vijf seconden zijn de juiste startgetallen.
Niets hiervan is glamoureus. Niets hiervan maakt het product beter. Alles hiervan verandert een storing van twee uur in een blip van vijftien seconden die de on-call-engineer in Sentry opmerkt en waar hij zijn schouders bij ophaalt. Dat is de deal die je maakt. Ik ben nog geen founder tegengekomen die er spijt van had.
FAQ
Wat is een goede waarde voor Rails statement_timeout in productie?
Vijf seconden is de juiste default voor een web-facing Postgres-rol. Ruim negentig procent van de legitieme applicatiequeries in een goed geïndexeerde Rails-app is binnen 500 milliseconden terug; vijf seconden vangt runaway queries af zonder de tail latency van legitieme trage endpoints kapot te maken. Rapporten en exports die echt langer nodig hebben scope je met SET LOCAL statement_timeout in een transactie, niet door de globale default te verhogen.
Werkt rack-timeout met Puma in Rails 8?
Ja. rack-timeout is transport-onafhankelijke middleware en draait identiek onder Puma, Unicorn en Falcon. Op Puma is het belangrijke detail dat wait_timeout meet hoe lang een request in Puma’s request-queue stond voordat een worker het oppakte — combineer een korte wait_timeout met service_past_wait = false en Puma shed automatisch load wanneer workers verzadigd raken, in plaats van requests af te handelen die de client al opgegeven heeft.
Heb ik job-timeouts nodig als ik al retries heb?
Ja. Retries bepalen wat er gebeurt nadat een job faalt; timeouts bepalen wanneer een job faalt. Zonder timeout houdt een runaway job zijn worker voor eeuwig vast en wordt een retry nooit getriggerd. De twee mechanismen werken samen: de timeout zet een plafond op één poging, en het retry-beleid — met exponential backoff — beslist of het opnieuw geprobeerd wordt.
Hoe zet ik verschillende Rails timeouts voor admin-endpoints en publieke endpoints?
Het schoonste patroon is een aparte Postgres-rol en een rack-timeout-override op controllerniveau. Geef admin-acties een rol met een langere statement_timeout, verbind daarmee via een aparte connection pool op een specifieke ActiveRecord::Base-subklasse, en override het rack-timeout-plafond in de controller met Rack::Timeout.override_timeout_for_this_request(60). Verhoog niet de globale defaults van de hele applicatie om een paar trage admin-pagina’s mogelijk te maken.
Hulp nodig bij het hardening van een Rails-applicatie tegen productiecascades? TTB Software is gespecialiseerd in Rails-betrouwbaarheid, performance, en fractional CTO-werk. We doen dit al negentien jaar en we installeren deze timeouts op elk project dat we aanraken.
Related Articles
Rails Postgres-back-ups met pgBackRest: point-in-time recovery, S3-opslag en restore drills
Rails Postgres-back-ups met pgBackRest: volledige en differentiële back-ups naar S3, WAL-archivering, point-in-time r...
Rails Searchkick: Productie Full-Text Zoeken met Elasticsearch en OpenSearch
Rails Searchkick integreert Elasticsearch en OpenSearch met ActiveRecord via synoniemen, boosting, facetten, autocomp...
Rails Samengestelde Primaire Sleutels: CPK, Legacy Schema's en Natuurlijke Sleutels in ActiveRecord
Rails samengestelde primaire sleutels laten ActiveRecord werken met meerkolomse PKs. Leer CPK-setup, associaties, leg...