35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English 35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English
Rails Active Job Retries: Exponential Backoff, Circuit Breakers en Dead Letter Queues

Rails Active Job Retries: Exponential Backoff, Circuit Breakers en Dead Letter Queues

Roger Heykoop
Ruby on Rails, DevOps
Rails Active Job retries: exponential backoff, circuit breakers, dead letter queues, idempotentie en productiepatronen voor robuust achtergrondwerk.

Een founder belde me afgelopen september om 2 uur ‘s nachts op een dinsdag omdat zijn SaaS al zestien dagen stilletjes geld aan het verliezen was. Het verhaal was simpel: zijn Stripe-webhookhandler enqueude een Active Job om het account van de klant te provisioneren; de provisioning-job riep een third-party API aan; die third-party API gooide elke paar minuten tien seconden lang 503-fouten; de standaard retries van de job waren op; en driehonderdveertig betalende klanten waren stilletjes in een “betaling ontvangen, account nooit aangemaakt”-zwart gat gevallen. De job had retry_on StandardError, attempts: 3. Hij zag er correct uit. Hij was fout op die specifieke manier waar productie zo van houdt: hij faalde elegant in het niets.

Na negentien jaar Rails heb ik teams achtergrondjobs zien shippen met de vrolijke aanname dat de job zal draaien, succesvol zal draaien, en precies één keer zal draaien. Geen van die drie klopt op schaal. Rails Active Job retries zijn het verschil tussen een app die de slechte middag bij AWS overleeft en een app die klantengeld verliest. Deze post is het draaiboek dat ik aan elk team geef waarmee ik werk: hoe je Rails Active Job retries ontwerpt die echt herstellen, wanneer je exponential backoff gebruikt, hoe je circuit breakers inbouwt, en waar je een dead letter queue plaatst zodat het volgende telefoontje om 2 uur ‘s nachts een dashboard wakker maakt in plaats van een CTO.

Waarom Standaard Active Job Retries Niet Genoeg Zijn

Het standaardgedrag van Rails Active Job retries is dat elke onafgehandelde exception naar boven borrelt, de queue-adapter een falen ziet, en afhankelijk van de adapter wordt de job ofwel stilletjes weggegooid (sommige oude configuraties), opnieuw geprobeerd met adapter-specifieke backoff (Sidekiq), of opnieuw geprobeerd met wat je in retry_on hebt geschreven (Solid Queue, GoodJob, Que).

Drie dingen gaan fout met de standaard:

  1. Aantal pogingen is te laag. attempts: 3 is het voorbeeld dat iedereen kopieert. Een storing van 5 minuten bij een downstream-provider met 15 seconden backoff probeert drie keer in 45 seconden en geeft het dan op. Je moest een uur lang blijven proberen.
  2. Backoff is uniform. Een vlakke “wacht 30 seconden en probeer opnieuw” hamert de herstellende downstream-service zodra hij terugkomt — elke jobs in je queue raakt hem op hetzelfde moment, en je triggert opnieuw de outage waar je op zat te wachten.
  3. Gefaalde jobs verdwijnen. Zonder een expliciete dead letter-strategie wordt een definitief gefaalde job een rij in een database-tabel of een regel in een logbestand. Niemand kijkt ernaar. De klant krijgt nooit de e-mail. Je komt erachter in oktober.

Het juiste model: elke job is een unit of work die kan falen om een van drie redenen, en elke reden heeft zijn eigen reactie nodig. Tijdelijke fouten (netwerkglitch, downstream 503, DB-deadlock) willen retries met exponential backoff. Persistente fouten (verkeerde input, verlopen token) willen hooguit één retry, daarna escalatie. Code-bugs (NoMethodError, ArgumentError) willen nul retries — die willen een deploy.

Exponential Backoff met Jitter

Het allernuttigste patroon in Rails Active Job retries is exponential backoff met jitter. Active Job ondersteunt dit native sinds Rails 7.1:

class ProvisionAccountJob < ApplicationJob
  queue_as :default

  retry_on Stripe::APIConnectionError,
           Net::OpenTimeout,
           Net::ReadTimeout,
           wait: :polynomially_longer,
           attempts: 10

  discard_on ActiveJob::DeserializationError

  def perform(customer_id)
    customer = Customer.find(customer_id)
    Provisioning::Pipeline.new(customer).call
  end
end

wait: :polynomially_longer is Rails’ ingebouwde helper die backt off als executions ** 4 + 2 + rand(executions) seconden. Na tien pogingen is de totale verstreken wachttijd enkele uren — lang genoeg om bijna elke echte outage uit te zitten die ik heb gezien. De rand(executions) is jitter: de kleine willekeurige verschuiving die voorkomt dat elke retryende job in je vloot de herstellende API op dezelfde milliseconde raakt.

Heb je fijnere controle nodig, schrijf de functie zelf:

class ChargeCardJob < ApplicationJob
  retry_on Stripe::RateLimitError, attempts: 8 do |job, error|
    delay = (2 ** job.executions) + rand(0..30)
    job.class.set(wait: delay.seconds).perform_later(*job.arguments)
  end
end

Twee dingen om op te merken. Ten eerste geeft de blokvorm je de job en de error en laat hij je expliciet herplannen — je kunt response-headers lezen voor een Retry-After-waarde en die gebruiken. Ten tweede, wanneer een downstream API je Retry-After: 60 vertelt, respecteer je dat. De snelste manier om permanent rate-limited te worden door Stripe of Twilio is hun backoff-hints negeren.

De post over Rails webhook-verwerking met idempotentie maakt het verwante punt over de inkomende kant: bij elke netwerkgrens worden requests opnieuw geprobeerd, gedupliceerd en herordend. Het jobsysteem is de uitgaande kant van hetzelfde probleem.

Idempotentie Is Niet Optioneel

Als je job opnieuw gaat proberen, gaat hij twee keer draaien. Soms tien keer. Rails Active Job retries zijn alleen veilig wanneer de job idempotent is — twee keer draaien levert dezelfde eindstaat op als één keer draaien.

Het patroon dat ik elk team aanraad:

class ProvisionAccountJob < ApplicationJob
  retry_on Net::OpenTimeout, attempts: 8, wait: :polynomially_longer

  def perform(customer_id)
    customer = Customer.find(customer_id)

    return if customer.provisioned_at.present?

    ApplicationRecord.transaction do
      customer.with_lock do
        return if customer.reload.provisioned_at.present?

        Provisioning::Pipeline.new(customer).call
        customer.update!(provisioned_at: Time.current)
      end
    end
  end
end

Drie waarborgen: een vroege return op het ongeladen record (goedkoop), een row-level lock om gelijktijdige retries te serialiseren, en een hercheck na de lock voor het geval een andere worker de klant provisioneerde terwijl wij op de lock wachtten. Dit is bretels en een riem. In productie redden ze je uiteindelijk allebei.

Voor jobs die externe API’s aanroepen, zijn idempotency keys het juiste gereedschap. Stripe, Twilio, SendGrid en de meeste moderne providers accepteren een Idempotency-Key-header. Genereer er deterministisch eentje uit de jobargumenten en geef hem mee bij elke retry:

def perform(charge_id)
  charge = Charge.find(charge_id)
  Stripe::Charge.create(
    {
      amount: charge.amount_cents,
      currency: "usd",
      customer: charge.stripe_customer_id,
    },
    idempotency_key: "charge-#{charge.id}-attempt-anchor"
  )
end

De key is de charge, niet de poging. Elke retry stuurt dezelfde key. Stripe dedupliceert server-side. De klant wordt één keer gefactureerd, ook als je job elf keer draait. Voor diepere garanties geven Postgres advisory locks je wederzijdse uitsluiting tussen workers zonder een row lock.

Errors Onderscheiden: Retry, Discard, Escalate

De standaardmindset van rescue_from StandardError is gif voor Rails Active Job retries. Verschillende errors verdienen verschillende reacties:

class SendInvoiceJob < ApplicationJob
  # Probeer tijdelijke infrastructuurfouten ruimhartig opnieuw.
  retry_on Net::OpenTimeout, Net::ReadTimeout,
           Errno::ECONNRESET, Errno::ECONNREFUSED,
           wait: :polynomially_longer, attempts: 10

  # Probeer de eigen tijdelijke errors van de API met hun hint.
  retry_on Stripe::RateLimitError, attempts: 12 do |job, error|
    job.class.set(wait: error.retry_after || 30).perform_later(*job.arguments)
  end

  # Slechte input — geen retry, escaleren.
  discard_on ActiveRecord::RecordNotFound do |job, error|
    Sentry.capture_exception(error, extra: { job: job.class.name, args: job.arguments })
  end

  # Programmeerfouten — laat ze luid falen.
  # (Alles wat hierboven niet wordt afgehandeld borrelt door en gaat naar dead-letter.)

  def perform(invoice_id)
    Invoice.find(invoice_id).deliver!
  end
end

De vorm van dat bestand is de les. Er is geen enkele retry-policy. Er zijn policies per error-klasse, georganiseerd van “absoluut opnieuw proberen” naar “absoluut opgeven”. Als je niet kunt verwoorden welke exceptions opnieuw moeten en welke niet, weet je nog niet wat je job doet.

Circuit Breakers voor Downstream Providers

Exponential backoff is een patroon per job. Wanneer al je jobs dezelfde downstream aanroepen — stel, elke webhookprocessor belt SendGrid — heb je ook een fleet-level reactie nodig wanneer de downstream uitvalt. Daar dient een circuit breaker voor.

Het patroon in pure Ruby (de stoplight-gem verpakt dit netjes):

class SendGridClient
  Failure = Class.new(StandardError)

  def deliver(message)
    Stoplight("sendgrid")
      .with_threshold(5)
      .with_cool_off_time(60)
      .with_error_handler { |error, handle| handle.call(error) unless error.is_a?(Failure) }
      .run { post_to_sendgrid(message) }
  end
end

Vijf opeenvolgende mislukkingen en het circuit opent: volgende calls falen direct zestig seconden lang zonder SendGrid te raken. Jobs die een open circuit raken, gooien een bekende exception die retry_on met backoff herplant. De downstream krijgt een kans om te herstellen in plaats van geramd te worden door tienduizend gequeuede jobs op het moment dat hij terugkomt. De breaker sluit na een succesvolle probe. Latency keert terug naar normaal.

Een subtiel punt: de breaker-state moet ergens leven die gedeeld wordt tussen workers. Stoplight is standaard in-process; in productie wijs je het naar Redis zodat al je workers dezelfde circuit-state zien. Zonder gedeelde state ontdekt elke worker de outage onafhankelijk en heb je geen fleet-level bescherming.

Dead Letter Queues: Waar Gefaalde Jobs Heen Gaan om Gevonden te Worden

Nadat alle retries op zijn, moet de job ergens heen waar een mens hem kan vinden. De queue-adapter doet er hier toe. Sidekiq heeft een ingebouwde dead-set. Solid Queue slaat gefaalde jobs op in solid_queue_failed_executions met de volledige error, backtrace en argumenten. GoodJob houdt ze in good_jobs met error en finished_at.

De fout die teams maken is vertrouwen op de standaard-UI van de adapter als operationeel eindpunt. Dat is hij niet. Het patroon dat werkt:

  1. Schrijf na uitputting van retries een domein-betekenisvolle rij naar een failed_jobs-tabel die je zelf bezit. Inclusief de jobklasse, argumenten, errorbericht, errorklasse en een resolved_at.
  2. Page on-call wanneer de tabel boven een drempel komt voor een kritieke jobklasse.
  3. Bouw een kleine admin-UI die gefaalde jobs toont, een mens ze laat inspecteren, en een one-click retry en discard aanbiedt.
  4. Maak een wekelijkse review van de tabel onderdeel van de on-call handoff. Patronen van falen zijn een gratis roadmap.
class ApplicationJob < ActiveJob::Base
  rescue_from(StandardError) do |error|
    if executions >= self.class.retry_attempts_for(error)
      FailedJob.create!(
        job_class: self.class.name,
        arguments: arguments,
        error_class: error.class.name,
        error_message: error.message,
        backtrace: error.backtrace.first(20).join("\n"),
        failed_at: Time.current
      )
      Sentry.capture_exception(error)
    end
    raise
  end
end

De raise aan het eind doet ertoe: je wilt nog steeds dat de queue-adapter de job als gefaald markeert. De FailedJob-rij is jouw operationele laag bovenop, geen vervanging voor de tracking van de adapter. De post over Rails Solid Queue behandelt de onderliggende mechanica als je Redis helemaal wilt overslaan.

Time-Boxing voor Lang Lopende Jobs

Exponential backoff is gevaarlijk in combinatie met jobs die al lang duren. Een job van 12 minuten die tien keer polynomiaal opnieuw probeert kan een week in het systeem zitten. Rails 7.1 voegde sidekiq_options expires_in: toe en Solid Queue/GoodJob hebben vergelijkbare concepten; Active Job zelf heeft geen first-class deadline, dus stel er expliciet eentje in:

class GenerateReportJob < ApplicationJob
  DEADLINE = 30.minutes

  retry_on Net::OpenTimeout, attempts: 5, wait: :polynomially_longer

  def perform(report_id, enqueued_at: Time.current.iso8601)
    if Time.current - Time.parse(enqueued_at) > DEADLINE
      Rails.logger.warn("dropping #{self.class} #{report_id}: deadline exceeded")
      ReportStatusMailer.expired(report_id).deliver_later
      return
    end

    Reports::Generator.new(report_id).call
  end
end

De job draagt zijn eigen enqueued time mee en verwijdert zichzelf na de deadline. De klant krijgt een “we konden dit niet op tijd genereren, probeer het opnieuw”-e-mail in plaats van een rapport dat de volgende ochtend aankomt.

Observability: Je Kunt Niet Repareren Wat Je Niet Kunt Zien

Elk team dat ik audit met retry-problemen heeft ook zichtbaarheidsproblemen. Sluit minimaal twee dingen aan:

ActiveSupport::Notifications.subscribe("retry_stopped.active_job") do |*args|
  event = ActiveSupport::Notifications::Event.new(*args)
  job = event.payload[:job]
  Statsd.increment("active_job.retry_stopped", tags: ["job:#{job.class.name}"])
  Sentry.capture_exception(event.payload[:error], extra: { job: job.class.name, args: job.arguments })
end

ActiveSupport::Notifications.subscribe("enqueue_retry.active_job") do |*args|
  event = ActiveSupport::Notifications::Event.new(*args)
  Statsd.increment("active_job.retry", tags: ["job:#{event.payload[:job].class.name}"])
end

Nu heb je per jobklasse een dashboardlijn voor “retried” en “gave up”. Een piek in de retry-rate is de leading indicator van een downstream-outage; een piek in “gave up” is de leading indicator van een incident. Page op de tweede. Houd de eerste in de gaten. Het team waarvoor ik dit herbouwde ging in twee weken van “we kwamen erachter via Twitter” naar “we paged onszelf voordat de klant het merkte”.

Productiecijfers

Drie apps waar het correct bedraden van Rails Active Job retries de uitkomst veranderde:

  • Een telehealth-planningsapp: een AWS-netwerkdegradatie van 4 uur die eerder 18.000 afspraakbevestigingsmails liet vallen, liet er precies nul vallen nadat we exponential backoff met attempts: 12 en een circuit breaker op de SMS-provider hadden bedraad. Totale kosten: 90 minuten pair programming.
  • Een fintech-onboardingflow: het introduceren van een failed_jobs-tabel en een admin-pagina van 5 regels reduceerde “ghost users die vastzitten in pending-state” van 120/maand naar minder dan 5/maand. Klantenservice-tickets over “waar is mijn account?” daalden 71%.
  • Een B2B SaaS: het vervangen van vaste wait: 30.seconds, attempts: 3 door :polynomially_longer, attempts: 10 en idempotency keys op elke externe call elimineerde een terugkerend wekelijks incident waarbij een wankele leverancier hun billing-pipeline platlegde.

De winsten waren klein in code en groot in operations. Elk van deze teams vertelde me daarna hetzelfde: “Ik kan niet geloven hoe stabiel de queue nu is.”

Veelgestelde Vragen

Wat is het juiste aantal pogingen voor Rails Active Job retries?

Het hangt af van wat de job aanroept en hoe lang de slechtste plausibele outage duurt. Voor puur databasewerk is 3 pogingen genoeg. Voor externe API-calls die afhankelijk zijn van provider-outages dekken 8 tot 12 pogingen met wait: :polynomially_longer incidenten van meerdere uren terwijl je onder een dag totale verstreken tijd blijft. Het getal is een functie van “hoe lang wil ik wachten voordat ik opgeef en een mens waarschuw?” niet een magische constante.

Zijn Rails Active Job retries veilig zonder idempotentie?

Nee. Elke job die opnieuw probeert kan twee keer draaien; de job zo ontwerpen dat twee keer draaien onschadelijk is, is de voorwaarde om retries überhaupt te gebruiken. Als je job een e-mail stuurt, een kaart belast of naar een extern systeem schrijft, heb je ofwel een idempotency key op de externe call nodig, een database-waarborg (“hebben we dit al gedaan?”), of beide. Zonder idempotentie maken retries een slechte dag erger door bijwerkingen te dupliceren.

Moet ik Sidekiq retry-opties of Active Job retry_on gebruiken?

Gebruik Active Job’s retry_on en discard_on. Ze werken over queue-adapters heen (Sidekiq, Solid Queue, GoodJob, Que) en laten je backends wisselen zonder jobs te herschrijven. Sidekiq’s adapter-specifieke retry is prima voor Sidekiq-only apps, maar je verliest portabiliteit en je kunt dezelfde policy niet uitdrukken op een Solid Queue-worker. Active Job-retries onthullen ook ActiveSupport::Notifications-events waar je op kunt abonneren voor observability.

Hoe probeer ik een gefaalde Active Job opnieuw nadat de retries op zijn?

Als je het falen naar een failed_jobs-tabel hebt gelogd, is opnieuw proberen Job.constantize.perform_later(*arguments) vanuit je admin-UI. Vertrouw je op de queue-adapter, dan heeft Sidekiq een “Retry”-knop in zijn dashboard, biedt Solid Queue failed_executions.retry_all en heeft GoodJob een vergelijkbare API. Het belangrijke deel is de actie één klik maken; hoe meer stappen het kost, hoe langer klanten wachten terwijl een mens logs leest.


Hulp nodig om een Rails-achtergrondjobpipeline zijn slechte middagen te laten overleven? TTB Software is gespecialiseerd in Rails-betrouwbaarheid, queue-architectuur en incident response voor productteams. We doen dit al negentien jaar.

#rails-active-job #active-job-retry #rails-background-jobs #exponential-backoff #circuit-breaker #dead-letter-queue #ruby-on-rails
R

About the Author

Roger Heykoop is een senior Ruby on Rails ontwikkelaar met 19+ jaar Rails ervaring en 35+ jaar ervaring in softwareontwikkeling. Hij is gespecialiseerd in Rails modernisering, performance optimalisatie, en AI-ondersteunde ontwikkeling.

Get in Touch

Share this article

Need Expert Rails Development?

Let's discuss how we can help you build or modernize your Rails application with 19+ years of expertise

Schedule a Free Consultation