<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: MarcoBlch</title>
    <description>The latest articles on DEV Community by MarcoBlch (@marcoblch).</description>
    <link>https://dev.to/marcoblch</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4112464%2F71d50e82-606f-4c4a-98bc-5443c44d14ac.jpg</url>
      <title>DEV Community: MarcoBlch</title>
      <link>https://dev.to/marcoblch</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/marcoblch"/>
    <language>en</language>
    <item>
      <title>I Added Circuit Breakers to a Rails App and Had to Patch the Gem Twice</title>
      <dc:creator>MarcoBlch</dc:creator>
      <pubDate>Sun, 06 Sep 2026 15:53:24 +0000</pubDate>
      <link>https://dev.to/marcoblch/i-added-circuit-breakers-to-a-rails-app-and-had-to-patch-the-gem-twice-4fja</link>
      <guid>https://dev.to/marcoblch/i-added-circuit-breakers-to-a-rails-app-and-had-to-patch-the-gem-twice-4fja</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📖 &lt;em&gt;Originally published on &lt;a href="https://meridianbuild.dev/blog/circuit-breakers-in-production-rails-patching-the-gem/" rel="noopener noreferrer"&gt;meridianbuild.dev&lt;/a&gt; my engineering blog documenting the real bugs and decisions behind &lt;a href="https://outfitmaker.ai" rel="noopener noreferrer"&gt;OutfitMaker&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://outfitmaker.ai" rel="noopener noreferrer"&gt;OutfitMaker&lt;/a&gt; leans on a lot of external services. Outfit suggestions, wardrobe image analysis, missing-item detection and trip planning all call Gemini through Vertex AI. The "Look Preview" feature calls a &lt;em&gt;second&lt;/em&gt; Google API for image generation (a whole story on its own &lt;a href="https://meridianbuild.dev/blog/fashn-to-gemini-vertex-ai-cant-return-images/" rel="noopener noreferrer"&gt;I wrote about that swap here&lt;/a&gt;). Product images come from Replicate. Affiliate suggestions come from Amazon via RapidAPI.&lt;/p&gt;

&lt;p&gt;Every one of those is a thing that can go down, get slow, or start rate-limiting me without warning. And when one does, the failure mode in a Rails app is ugly: a Sidekiq job retries, hammers the dead service, ties up a worker, the retry queue backs up, and the failure spreads to features that have nothing to do with the broken provider.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;circuit breaker&lt;/strong&gt; is the standard fix. It's a small state machine that sits in front of an external call. After a set number of failures inside a time window, it "opens" and while it's open, calls fail instantly instead of waiting on a dead service. After a cooldown it goes "half-open," lets one call through to test the water, and either closes (recovered) or opens again. The point isn't to make failures disappear. It's to make them &lt;em&gt;cheap and contained&lt;/em&gt; instead of expensive and contagious.&lt;/p&gt;

&lt;p&gt;I shipped circuit breakers across all six integrations in two phases in early May 2026 — Vertex services first (&lt;code&gt;b7f9fd3&lt;/code&gt;, PR #63), then Look Preview, Replicate and Amazon (&lt;code&gt;b637695&lt;/code&gt;, PR #67). I reached for the &lt;a href="https://rubygems.org/gems/breaker_machines" rel="noopener noreferrer"&gt;&lt;code&gt;breaker_machines&lt;/code&gt;&lt;/a&gt; gem, pinned to &lt;code&gt;0.10.3&lt;/code&gt;. The DSL is clean and the wiring took an afternoon.&lt;/p&gt;

&lt;p&gt;Then I tried to actually make a circuit trip, and the afternoon turned into a week.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision one: separate circuits, not one big Vertex circuit
&lt;/h2&gt;

&lt;p&gt;Four of my services hit Gemini Vertex AI on the same &lt;code&gt;gemini-2.5-flash&lt;/code&gt; model. The tempting design is a single &lt;code&gt;:gemini_vertex&lt;/code&gt; circuit they all share, because they genuinely share fate one Vertex outage breaks all four.&lt;/p&gt;

&lt;p&gt;I gave each service its own circuit instead. The reasoning is in a design note in the shared mixin:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# DESIGN NOTE separate Vertex circuits despite shared model&lt;/span&gt;
&lt;span class="c1"&gt;#   1. Per-feature blast radius. The April 23 incident came from&lt;/span&gt;
&lt;span class="c1"&gt;#      ImageAnalysisJob under load. A shared circuit would have opened&lt;/span&gt;
&lt;span class="c1"&gt;#      and silently degraded outfit suggestions and trip planning by&lt;/span&gt;
&lt;span class="c1"&gt;#      contagion — features paid users depend on.&lt;/span&gt;
&lt;span class="c1"&gt;#   2. Per-feature observability. Sentry breadcrumbs and metrics tag by&lt;/span&gt;
&lt;span class="c1"&gt;#      circuit name. Separate circuits = a glance tells you which feature&lt;/span&gt;
&lt;span class="c1"&gt;#      tripped, no log mining.&lt;/span&gt;
&lt;span class="c1"&gt;#   3. Per-feature cost of false positives. A false-positive open on&lt;/span&gt;
&lt;span class="c1"&gt;#      MissingItemDetector returns [] (graceful). A false-positive open on&lt;/span&gt;
&lt;span class="c1"&gt;#      OutfitSuggestionService blocks a paywalled flow.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A shared circuit is one fewer thing to configure, but it couples the blast radius of every feature to the noisiest one. The whole reason I was adding breakers was to &lt;em&gt;stop&lt;/em&gt; one feature's failure from spreading. A shared circuit would have quietly re-introduced exactly that.&lt;/p&gt;

&lt;p&gt;To keep four near-identical declarations DRY without coupling them, there's a class-method helper:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;gemini_vertex_circuit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;circuit_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                          &lt;span class="n"&gt;service_exception&lt;/span&gt;&lt;span class="p"&gt;:,&lt;/span&gt;
                          &lt;span class="n"&gt;network_errors&lt;/span&gt;&lt;span class="p"&gt;:,&lt;/span&gt;
                          &lt;span class="ss"&gt;failures: &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                          &lt;span class="ss"&gt;within: &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;minute&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                          &lt;span class="ss"&gt;reset_after_seconds: &lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                          &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;circuit&lt;/span&gt; &lt;span class="n"&gt;circuit_name&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;threshold&lt;/span&gt; &lt;span class="ss"&gt;failures: &lt;/span&gt;&lt;span class="n"&gt;failures&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;within: &lt;/span&gt;&lt;span class="n"&gt;within&lt;/span&gt;
    &lt;span class="n"&gt;reset_after&lt;/span&gt; &lt;span class="n"&gt;reset_after_seconds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;jitter: &lt;/span&gt;&lt;span class="mf"&gt;0.25&lt;/span&gt;
    &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;network_errors&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;service_exception&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;instance_exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replicate gets a different template &lt;code&gt;failures: 5&lt;/code&gt; within &lt;code&gt;1.hour&lt;/code&gt; instead of &lt;code&gt;3&lt;/code&gt; within &lt;code&gt;1.minute&lt;/code&gt; because its traffic is roughly one invocation a day right now, so a one-minute window is statistically unreachable, and its published rate limit makes short failure clusters more likely than a real outage. The thresholds describe the service, not a global default.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision two: where circuit state lives
&lt;/h2&gt;

&lt;p&gt;Circuit state has to be shared across processes my web dynos and Sidekiq workers all need to agree that a circuit is open. So it goes in Redis. The non-obvious part is &lt;em&gt;which&lt;/em&gt; Redis.&lt;/p&gt;

&lt;p&gt;I gave it a dedicated database (&lt;code&gt;db 1&lt;/code&gt;), separate from &lt;code&gt;Rails.cache&lt;/code&gt; (&lt;code&gt;db 0&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Why a separate Redis DB instead of reusing Rails.cache?&lt;/span&gt;
&lt;span class="c1"&gt;#   1. Rails.cache.clear (and Rack::Attack key churn) wipe everything in db 0.&lt;/span&gt;
&lt;span class="c1"&gt;#      Circuit breaker state must survive cache flushes losing it during an&lt;/span&gt;
&lt;span class="c1"&gt;#      outage would re-arm the breaker mid-incident and let traffic stampede&lt;/span&gt;
&lt;span class="c1"&gt;#      a service that's already down.&lt;/span&gt;
&lt;span class="c1"&gt;#   2. Namespace ("bm") is a defense in depth, not a substitute for db&lt;/span&gt;
&lt;span class="c1"&gt;#      isolation: ActiveSupport's :redis_cache_store only namespaces keys, it&lt;/span&gt;
&lt;span class="c1"&gt;#      does not isolate the Redis DB.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The TTL is 24 hours long enough to outlast a real multi-hour provider outage, because a short TTL would silently drop circuit state at 3am during a low-traffic night, right before morning traffic resumes. There's also a build-time branch: when assets compile with &lt;code&gt;SECRET_KEY_BASE_DUMMY&lt;/code&gt; set and no Redis, the store falls back to a &lt;code&gt;NullStore&lt;/code&gt; so the breaker becomes a no-op. No traffic at build time means no state to track.&lt;/p&gt;

&lt;p&gt;All of that is design. None of it is what cost me the week. The week went to discovering that with this exact setup, &lt;strong&gt;the circuit never actually tripped.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug one: the breaker counted to zero forever
&lt;/h2&gt;

&lt;p&gt;I wrote a test that fired enough failures to cross the threshold and asserted the circuit opened. It didn't. The failure count stayed at zero no matter how many exceptions I threw.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;breaker_machines&lt;/code&gt;' cache adapter counts failures by calling &lt;code&gt;increment&lt;/code&gt; on the cache store and then reading the value back. With &lt;code&gt;RedisCacheStore&lt;/code&gt;, &lt;code&gt;increment&lt;/code&gt; issues a raw Redis &lt;code&gt;INCR&lt;/code&gt;, which stores a plain string &lt;code&gt;"3"&lt;/code&gt;. But the read comes back through ActiveSupport's default deserialization path, which tries to un-marshal that string, fails, and returns &lt;code&gt;nil&lt;/code&gt;. &lt;code&gt;nil.to_i&lt;/code&gt; is &lt;code&gt;0&lt;/code&gt;. The counter is structurally incapable of going up.&lt;/p&gt;

&lt;p&gt;The fix is four lines read the counter raw and coerce it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;BreakerMachinesCacheRedisFix&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_window_count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;window_seconds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="vi"&gt;@cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;respond_to?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:increment&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="vi"&gt;@cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;raw: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to_i&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;
      &lt;span class="k"&gt;super&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="no"&gt;BreakerMachines&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Storage&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prepend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;BreakerMachinesCacheRedisFix&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Finding the four lines took the better part of two days. The patch itself is guarded so it can't rot silently:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="k"&gt;defined?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;BreakerMachines&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;VERSION&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="no"&gt;BreakerMachines&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;VERSION&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"0.10.3"&lt;/span&gt;
  &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="s2"&gt;"breaker_machines_cache_patch is pinned to 0.10.3, currently loaded: ..."&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="no"&gt;BreakerMachines&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Storage&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;instance_method&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:get_window_count&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;arity&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
  &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="s2"&gt;"BreakerMachines::Storage::Cache#get_window_count signature changed; ..."&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If I ever bump the gem, the app refuses to boot until I've re-checked whether the bug still exists. A monkey patch you forget about is worse than the bug it fixed. (This is the same instinct as the atomic rewrite in my &lt;a href="https://meridianbuild.dev/blog/toctou-in-my-gemini-rate-limiter/" rel="noopener noreferrer"&gt;Gemini rate-limiter post&lt;/a&gt;: when a read-then-write straddles a process boundary, the boundary is where the bug hides.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug two: a fresh worker forgot the circuit was open
&lt;/h2&gt;

&lt;p&gt;With the counter fixed, circuits tripped correctly. Then I tested the scenario that actually matters in production: a worker boots into a world where the circuit is &lt;em&gt;already&lt;/em&gt; open, set by some other process. It should refuse calls immediately. Instead, its first call sailed straight through to the dead service.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;breaker_machines&lt;/code&gt; does try to handle this. Its &lt;code&gt;Circuit#initialize&lt;/code&gt; reads the stored status from Redis and assigns &lt;code&gt;self.status = "open"&lt;/code&gt;. The problem is &lt;em&gt;ordering&lt;/em&gt;. Right after &lt;code&gt;initialize&lt;/code&gt; returns, the underlying &lt;code&gt;state_machines&lt;/code&gt; gem runs its own &lt;code&gt;initialize_states&lt;/code&gt; lifecycle hook, which resets &lt;code&gt;@status&lt;/code&gt; back to the initial value &lt;code&gt;:closed&lt;/code&gt; because the manual assignment never tripped the flag that tells &lt;code&gt;state_machines&lt;/code&gt; "this attribute is already set." The restore happens, then gets quietly overwritten.&lt;/p&gt;

&lt;p&gt;The trace, captured in the patch's own documentation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[restore] stored=#&amp;lt;Status status=:open, opened_at=...&amp;gt;
[restore] after assign: status=open
[trace status= called with "closed"] caller=[
  "state_machines/machine/state_methods.rb:88:in `write'",
  "state_machines/machine/state_methods.rb:35:in `initialize_state'",
  "state_machines/machine_collection.rb:36:in `block in initialize_states'"
]
FINAL: closed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix is to restore &lt;em&gt;again&lt;/em&gt;, after the lifecycle is done:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;BreakerMachinesStateRestoreFix&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt;
    &lt;span class="k"&gt;super&lt;/span&gt;
    &lt;span class="n"&gt;restore_status_from_storage&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="vi"&gt;@storage&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="no"&gt;BreakerMachines&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Circuit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prepend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;BreakerMachinesStateRestoreFix&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;super&lt;/code&gt; runs the full chain — including the &lt;code&gt;state_machines&lt;/code&gt; reset. Then I re-apply the stored status, this time with nothing left to clobber it. The restore is just a read-and-assign, so calling it twice is harmless.&lt;/p&gt;

&lt;p&gt;Why does this matter enough to patch a gem? Because of how the failure scales. Every fresh worker that boots during an outage and Sidekiq autoscaling spins up &lt;em&gt;more&lt;/em&gt; workers exactly when things are failing pays one wasted call to the dead service before its in-memory state catches up. Redeploying to ship a fix during an outage produces a whole fleet of forgetful workers. The cost is small per worker and real in aggregate, and it's worst at the exact moment you most need the breaker to hold. At one worker it's a rounding error; at five workers plus frequent deploys it's the pattern the breakers existed to remove.&lt;/p&gt;

&lt;p&gt;Because it's a &lt;code&gt;Module#prepend&lt;/code&gt; on the base &lt;code&gt;Circuit&lt;/code&gt; class, the fix applied to all six circuits at once with zero per-service changes — one Railway restart.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "open" looks like to a user
&lt;/h2&gt;

&lt;p&gt;A tripped circuit shouldn't show a stack trace. The fallback on the outfit-suggestion circuit distinguishes the two cases the gem lumps together:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;fallback&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
  &lt;span class="c1"&gt;# The breaker_machines fallback fires on EVERY whitelisted error, not&lt;/span&gt;
  &lt;span class="c1"&gt;# just open-state calls. Two distinct cases:&lt;/span&gt;
  &lt;span class="c1"&gt;#   1. Circuit OPEN — error is BreakerMachines::CircuitOpenError. Replace&lt;/span&gt;
  &lt;span class="c1"&gt;#      with a localized "temporarily unavailable" message, tag Sentry with&lt;/span&gt;
  &lt;span class="c1"&gt;#      the circuit name.&lt;/span&gt;
  &lt;span class="c1"&gt;#   2. Circuit CLOSED but raised an in-whitelist exception re-raise&lt;/span&gt;
  &lt;span class="c1"&gt;#      unchanged so the controller's existing rescue handles it like before.&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_a?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;BreakerMachines&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;CircuitOpenError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# ... localized message + Sentry tag ...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open circuit means "we already know this is down, here's a calm message." A closed circuit that raised means the real failure should flow through untouched. Collapsing those two into one generic error would have either hidden real bugs or shown scary copy for a known, handled state.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers I can and can't give you
&lt;/h2&gt;

&lt;p&gt;What I can stand behind, because it's in the repo: the gem is pinned to &lt;code&gt;0.10.3&lt;/code&gt;, both patches carry version guards that fail the boot on an unverified bump, and the two patch test files are 87 and 163 lines the state-restore one simulates two processes to prove a fresh circuit sees the stored &lt;code&gt;open&lt;/code&gt; state. The two bugs are reproducible on &lt;code&gt;0.10.3&lt;/code&gt;, and I confirmed the counter bug is still present in the &lt;code&gt;0.10.8&lt;/code&gt; source.&lt;/p&gt;

&lt;p&gt;What I can't give you is a clean "incidents prevented" graph. OutfitMaker isn't at the scale where provider outages hit daily, and I'm not going to dress up the breakers' value with numbers I don't have. The honest framing: this was insurance bought before the fire, and most of the work was discovering the policy didn't pay out until I patched it twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;A circuit breaker is a small state machine that fails fast and contains the blast radius when an external service goes down and for an AI-heavy app riding on three different Google and third-party APIs, it's not premature optimization, it's table stakes. But "add the gem" is the 20% of the work. The 80% is the boring, specific reality underneath: your cache store serializes counters in a way the adapter didn't expect, and your circuit state evaporates the instant a worker restarts unless the restore runs &lt;em&gt;after&lt;/em&gt; the state-machine lifecycle, not during it.&lt;/p&gt;

&lt;p&gt;If you're wiring breakers into a multi-process Rails deploy, write the two tests that actually matter before you trust the library: one that proves a circuit &lt;em&gt;trips&lt;/em&gt; under your real cache store, and one that proves a freshly booted process &lt;em&gt;sees&lt;/em&gt; a circuit another process already opened. Mine both failed against a popular, well-written gem. The breakers in &lt;a href="https://outfitmaker.ai" rel="noopener noreferrer"&gt;OutfitMaker&lt;/a&gt; only do their job because those two tests forced the patches that made them true.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>rails</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
