<?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: Pedro Rogério</title>
    <description>The latest articles on DEV Community by Pedro Rogério (@pinceladasdaweb).</description>
    <link>https://dev.to/pinceladasdaweb</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%2F430452%2F77516d62-cf12-421e-9309-b14b33c43000.jpg</url>
      <title>DEV Community: Pedro Rogério</title>
      <link>https://dev.to/pinceladasdaweb</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pinceladasdaweb"/>
    <language>en</language>
    <item>
      <title>My idempotency library had one job. A dropped connection made it run the payment twice.</title>
      <dc:creator>Pedro Rogério</dc:creator>
      <pubDate>Mon, 17 Aug 2026 12:32:17 +0000</pubDate>
      <link>https://dev.to/pinceladasdaweb/my-idempotency-library-had-one-job-a-dropped-connection-made-it-run-the-payment-twice-nnh</link>
      <guid>https://dev.to/pinceladasdaweb/my-idempotency-library-had-one-job-a-dropped-connection-made-it-run-the-payment-twice-nnh</guid>
      <description>&lt;p&gt;quayside is my idempotency library for Node.js. The whole API fits in one sentence: &lt;code&gt;execute(key, fn)&lt;/code&gt; runs a function exactly once per key — if it already ran, you get the stored result back; if it is running right now, you don't run it again. Pluggable storage (memory, Redis, Postgres, MySQL), HTTP adapters for Express, Fastify, Hono and NestJS, fencing tokens enforced inside the store so a holder that lost its lock can never overwrite a newer execution. Zero runtime dependencies.&lt;/p&gt;

&lt;p&gt;It shipped at 1.0.0 last week. This post is about the bug that outlived everything I threw at the code before release — a 100% mutation score across 1,254 mutants, a storage contract suite running against real servers, an adversarial review whose 26 verified findings I fixed or triaged — and waited for the final security pass to be found.&lt;/p&gt;

&lt;p&gt;It lived in four lines I had written days earlier, to fix a different bug. And it broke the library on the one scenario idempotency libraries exist for.&lt;/p&gt;

&lt;h2&gt;
  
  
  the one job
&lt;/h2&gt;

&lt;p&gt;Here is the canonical story every idempotency library tells in its README. A client POSTs a payment. The connection drops before the response arrives. The client has no idea whether the charge happened, so it retries with the same &lt;code&gt;Idempotency-Key&lt;/code&gt;. Without protection, the customer gets charged twice. With protection, the retry either waits, gets told the first attempt is still running, or replays the stored response.&lt;/p&gt;

&lt;p&gt;quayside's mechanism for this is deliberately boring: the atomic create-if-absent write &lt;strong&gt;is&lt;/strong&gt; the lock. &lt;code&gt;execute(key, fn)&lt;/code&gt; writes an &lt;code&gt;IN_PROGRESS&lt;/code&gt; record before your function runs — there is no separate locking step, so there is no gap between "checked" and "locked". Success transitions the record to &lt;code&gt;COMPLETED&lt;/code&gt; and replays it for a result TTL. Failure deletes it so retries run fresh. A crashed process is handled by the lock TTL. And every transition out of &lt;code&gt;IN_PROGRESS&lt;/code&gt; is guarded by a fencing token validated &lt;em&gt;inside the storage&lt;/em&gt; — Lua on Redis, token-conditional UPDATEs on SQL — so a holder that stalled through a GC pause and lost its lease gets a &lt;code&gt;FencingError&lt;/code&gt; from the store itself instead of silently overwriting the new holder's result.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Idempotency&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;quayside&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;RedisStorage&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;quayside/redis&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;idempotency&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Idempotency&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RedisStorage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;resultTtl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;24h&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// how long a completed result stays replayable&lt;/span&gt;
  &lt;span class="na"&gt;lockTtl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;30s&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;      &lt;span class="c1"&gt;// how long a crashed execution blocks the key&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;idempotency&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invoice:123&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;createPayment&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;// runs once; later calls replay the result&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dropped connection, then a retry. That is the use case. Hold that thought.&lt;/p&gt;

&lt;h2&gt;
  
  
  by the numbers, I had permission to feel safe
&lt;/h2&gt;

&lt;p&gt;I have written before about &lt;a href="https://dev.to/pinceladasdaweb/my-test-suite-had-100-coverage-mutation-testing-still-found-real-bugs-2man"&gt;what mutation testing did to a test suite with 100% coverage&lt;/a&gt;, so quayside got the same treatment from the start: every mutant dead, none suppressed — when a mutant was equivalent, the rule was to delete or restructure the code until it wasn't, never to annotate it away.&lt;/p&gt;

&lt;p&gt;The storage adapters all pass one shared contract suite against real servers via Testcontainers: 50-way concurrency races, &lt;code&gt;SIGKILL&lt;/code&gt; crash recovery, split-brain fencing where a stale holder tries to overwrite a newer execution and must be rejected by the store itself. On top of that, an adversarial review pass: 38 candidate findings, 26 surviving verification, the ten worst fixed before the tag.&lt;/p&gt;

&lt;p&gt;I am listing all of this not to brag but to set up the fall. The numbers measure the code you wrote. They say nothing about the code you wrote &lt;em&gt;last Tuesday to fix a review finding&lt;/em&gt;, which is where this story actually starts.&lt;/p&gt;

&lt;h2&gt;
  
  
  the review that found a stuck lock
&lt;/h2&gt;

&lt;p&gt;One of those 26 findings was in the Fastify adapter. Fastify's pipeline is hooks, not a wrapping function, so the adapter bridges the engine's continuation across two hooks: &lt;code&gt;preHandler&lt;/code&gt; acquires the lock and stashes a deferred; &lt;code&gt;onSend&lt;/code&gt; resolves it with the captured response, which the engine then stores.&lt;/p&gt;

&lt;p&gt;The finding: &lt;code&gt;onSend&lt;/code&gt; is not guaranteed to run. &lt;code&gt;reply.hijack()&lt;/code&gt; — the documented escape hatch for SSE, streaming, proxying — skips it. A handler that never answers skips it. In those cases the deferred never settles, and the key stays locked until the lock TTL expires. Thirty seconds of 409s for every retry, with nothing actually running. A real bug, confirmed, worth fixing.&lt;/p&gt;

&lt;p&gt;The fix looked obvious. Node's raw &lt;code&gt;ServerResponse&lt;/code&gt; emits &lt;code&gt;close&lt;/code&gt; for every request, no matter what the framework's lifecycle does. So: settle the capture when the connection closes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// onSend never runs for a hijacked reply, an aborted connection or a&lt;/span&gt;
&lt;span class="c1"&gt;// handler that never answers. Without this backstop the capture would&lt;/span&gt;
&lt;span class="c1"&gt;// never settle and the key would stay locked until its TTL expired.&lt;/span&gt;
&lt;span class="nx"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;close&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;resolveCapture&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tests green. Mutation score still perfect. I even wrote a test asserting the new behavior — "a reply that never reaches onSend releases the key on close" — and it passed, proudly.&lt;/p&gt;

&lt;p&gt;That test was asserting the bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  the fix was the bug
&lt;/h2&gt;

&lt;p&gt;The security pass flagged those four lines as its single HIGH finding, and the exploit chain reads like a checklist of things I knew individually and failed to compose:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A client POSTs &lt;code&gt;/payments&lt;/code&gt; with &lt;code&gt;Idempotency-Key: K&lt;/code&gt;. The handler starts the charge.&lt;/li&gt;
&lt;li&gt;Fifty milliseconds in, the client aborts the connection. Maybe a flaky mobile network. Maybe on purpose.&lt;/li&gt;
&lt;li&gt;Node does &lt;strong&gt;not&lt;/strong&gt; cancel the handler — the charge keeps running in the background.&lt;/li&gt;
&lt;li&gt;But &lt;code&gt;close&lt;/code&gt; fires on the raw response immediately. My backstop resolves the capture with &lt;code&gt;null&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;To the engine, &lt;code&gt;null&lt;/code&gt; means "the response was served but is not cacheable" — the code path built for oversized and binary bodies. It releases the record. Nothing is stored under K. The key is free.&lt;/li&gt;
&lt;li&gt;The handler finishes the charge. &lt;code&gt;onSend&lt;/code&gt; finally runs, tries to resolve the already-settled deferred — a no-op. No error, no warning. Perfect silence.&lt;/li&gt;
&lt;li&gt;The client retries with the same key K. The slot is empty. The handler runs again.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;One idempotency key. Two charges. Zero log lines.&lt;/p&gt;

&lt;p&gt;And notice what the trigger is: &lt;strong&gt;a dropped connection followed by a retry.&lt;/strong&gt; Not an exotic corner. Not an adversarial contortion. The exact scenario from the README, the one the library has as its single job. The deadlock I fixed was real but bounded — thirty seconds of 409s, annoying, visible. The fix I replaced it with was unbounded and invisible: it spent the library's entire reason to exist, silently, on its most common path.&lt;/p&gt;

&lt;p&gt;To be precise about what was at stake: this was caught before it ever reached a registry, in the last review gate before release. Nobody was double-charged. But it survived a mutation score of 100% and a full adversarial code review, because both of those examine the code that exists — and this was a &lt;em&gt;composition&lt;/em&gt; failure between a lifecycle event and the semantics of &lt;code&gt;null&lt;/code&gt;, each of which was individually correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  a close is a fact about the connection, not about the work
&lt;/h2&gt;

&lt;p&gt;The actual fix is one condition:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;close&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;writableEnded&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;resolveCapture&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&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;writableEnded&lt;/code&gt; distinguishes the two worlds that &lt;code&gt;close&lt;/code&gt; had been conflating. A hijacked reply &lt;em&gt;finished its response&lt;/em&gt; before the connection closed — settle it, release the key, that is the stuck-lock fix still working. A connection that died &lt;em&gt;before the response finished&lt;/em&gt; tells you nothing about the handler, because the handler is still running. Those keep the lock.&lt;/p&gt;

&lt;p&gt;What happens next is the part I find genuinely satisfying. The still-running handler eventually completes, and &lt;code&gt;onSend&lt;/code&gt; — which does run in this case, late — stores the outcome. So the retry that arrives during the charge gets a 409 with &lt;code&gt;Retry-After&lt;/code&gt;, and the retry that arrives after it gets the stored response replayed. The late completion is not lost work. It is exactly the result the retry came back for.&lt;/p&gt;

&lt;p&gt;If the process dies instead, the lock TTL reclaims the key — the same path that has always governed a crashed execution, tested by the same &lt;code&gt;SIGKILL&lt;/code&gt; test. The abort case did not need new machinery. It needed to stop being special-cased into the wrong bucket.&lt;/p&gt;

&lt;h2&gt;
  
  
  the asymmetry that decides it
&lt;/h2&gt;

&lt;p&gt;The lesson I wrote into the design doc afterwards, in bold, because I intend to re-read it:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Releasing a lock early is an integrity failure. Holding one too long is an availability cost. When the two trade off, hold.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The costs are not symmetric, and the pressure on you is inverted from the risk. A held lock is loud — users see 409s, dashboards light up, someone files an issue, and the damage is bounded by a TTL you chose. An early release is silent — everything returns 200, the work just quietly happens twice, and the bound is whatever a duplicated side effect costs your business. When I wrote the &lt;code&gt;close&lt;/code&gt; backstop I was optimizing away the loud, bounded problem, and I paid for it with the silent, unbounded one.&lt;/p&gt;

&lt;p&gt;The same asymmetry shows up anywhere a lease, a lock or a saga step can be torn down by an event that merely &lt;em&gt;correlates&lt;/em&gt; with the work being done: process managers reaping "idle" workers mid-transaction, orchestrators treating a lost heartbeat as a finished task, HTTP servers treating a gone client as a gone request. A connection event is a fact about the connection. The work has its own lifecycle, and only the work gets to say when it is over.&lt;/p&gt;

&lt;h2&gt;
  
  
  what else the adversarial passes caught
&lt;/h2&gt;

&lt;p&gt;Three more, briefly, because they rhyme with the theme — every one of them was invisible to coverage and visible to an adversary:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The wait loop trusted the key's identity across time.&lt;/strong&gt; With &lt;code&gt;onConflict: 'wait'&lt;/code&gt;, a waiter polls until the holder finishes. But the holder's lock can expire mid-wait and a &lt;em&gt;different payload&lt;/em&gt; can take the key over. The waiter was replaying whatever outcome showed up, including one that belonged to someone else's request body. It now re-checks the payload fingerprint on every poll and rejects with the same key-reuse error it would have thrown at the front door.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The HTTP kernel stored control flow as state.&lt;/strong&gt; "This response must not be cached" used to travel as a sentinel error persisted into the store and deleted right after — and a concurrent waiter could observe it in that window and be waved through with no protection at all. The engine now has a first-class &lt;code&gt;ctx.doNotStore()&lt;/code&gt;: keep the lock while running, store nothing, leave nothing for anyone to race. Storing control flow as shared state is a race you just haven't met yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A client-supplied key over the length cap returned a 500.&lt;/strong&gt; It is client input arriving in a header; it is a 400, with a stable error code, like every other policy limit. Blaming the server pages the wrong person.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  try it
&lt;/h2&gt;

&lt;p&gt;quayside is at 1.1.0 — the release after 1.0.0 routed persisted &lt;em&gt;failures&lt;/em&gt; through the configurable codec too, so if you encrypt results at rest, your error messages and stack traces are no longer sitting next to the ciphertext in plaintext. Everything in this post ships tested: the abort scenario, the takeover scenario, the sentinel window, all pinned by regression tests that fail on the vulnerable versions.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;quayside
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Idempotency&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;quayside&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;PostgresStorage&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;quayside/postgres&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;idempotency&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Idempotency&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PostgresStorage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="c1"&gt;// core, no HTTP required — queue consumers, cron jobs, CLIs&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;idempotency&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ignoreFields&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;meta.timestamp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;processOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four storage backends behind one contract suite, HTTP adapters that replay status, headers and body faithfully per the IETF &lt;code&gt;Idempotency-Key&lt;/code&gt; draft, Prometheus and OpenTelemetry integrations, zero runtime dependencies. Repo: &lt;a href="https://github.com/pinceladasdaweb/quayside" rel="noopener noreferrer"&gt;github.com/pinceladasdaweb/quayside&lt;/a&gt; — the docs include the full state machine and the design notes behind everything above.&lt;/p&gt;

&lt;p&gt;If you take one thing from this post, take the asymmetry. The next time an event tempts you to release a lock early because holding it is inconvenient: the inconvenience is bounded and visible, the early release is unbounded and silent. Hold.&lt;/p&gt;

</description>
      <category>node</category>
      <category>typescript</category>
      <category>distributedsystems</category>
      <category>opensource</category>
    </item>
    <item>
      <title>"half-open" twice is not the same state: the bug that shaped breakwater 1.0</title>
      <dc:creator>Pedro Rogério</dc:creator>
      <pubDate>Sat, 15 Aug 2026 17:19:55 +0000</pubDate>
      <link>https://dev.to/pinceladasdaweb/half-open-twice-is-not-the-same-state-the-bug-that-shaped-breakwater-10-4m6c</link>
      <guid>https://dev.to/pinceladasdaweb/half-open-twice-is-not-the-same-state-the-bug-that-shaped-breakwater-10-4m6c</guid>
      <description>&lt;p&gt;&lt;a href="https://github.com/pinceladasdaweb/breakwater" rel="noopener noreferrer"&gt;breakwater&lt;/a&gt; is my resilience toolkit for Node.js — retry, circuit breaker, timeout, bulkhead, rate limiting, stale-while-open caching, all composable, with observability built in. It just hit &lt;code&gt;1.0.0&lt;/code&gt;, and the headline feature is the one no Node library did well: &lt;strong&gt;a circuit breaker whose state is shared across every instance of your service&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;One instance sees the outage and trips the breaker. The others fail fast immediately, without each having to discover the same outage on their own users. When the cooldown elapses, exactly one of them probes the recovering dependency while the rest keep waiting.&lt;/p&gt;

&lt;p&gt;That is the pitch. This post is about the two things I got wrong on the way there, because they were both the kind of wrong that looks right.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 1: a state machine where the same name means two different things
&lt;/h2&gt;

&lt;p&gt;A circuit breaker is a tiny state machine. Closed, open, half-open. In one process you protect transitions with nothing at all — JavaScript is single-threaded, and the code between two &lt;code&gt;await&lt;/code&gt;s cannot be interrupted.&lt;/p&gt;

&lt;p&gt;Share that state across N instances and every transition becomes a compare-and-set. That much I knew. So the store interface had:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;transition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;   &lt;span class="c1"&gt;// swap only if the state is still `from`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Atomic in Redis via a Lua script. Looks correct. It is not.&lt;/p&gt;

&lt;p&gt;Here is the sequence that breaks it. The circuit is half-open and one instance is probing:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A probe &lt;strong&gt;fails&lt;/strong&gt;. The instance decides: reopen the circuit.&lt;/li&gt;
&lt;li&gt;That decision travels — a Lua round trip to Redis, a few milliseconds.&lt;/li&gt;
&lt;li&gt;In those milliseconds, &lt;strong&gt;another probe succeeds&lt;/strong&gt;, reaches the majority, and closes the circuit. Traffic resumes. It fails again. The circuit reopens, waits out the cooldown, and enters half-open &lt;strong&gt;again&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Now the first instance's swap lands. It says: &lt;em&gt;"if the state is still &lt;code&gt;half-open&lt;/code&gt;, make it &lt;code&gt;open&lt;/code&gt;."&lt;/em&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The state &lt;strong&gt;is&lt;/strong&gt; &lt;code&gt;half-open&lt;/code&gt;. The swap succeeds. And it is completely wrong — that decision belonged to a period that ended three transitions ago, and it just killed a recovery that had nothing to do with it.&lt;/p&gt;

&lt;p&gt;This is the &lt;a href="https://en.wikipedia.org/wiki/ABA_problem" rel="noopener noreferrer"&gt;ABA problem&lt;/a&gt;, and it is easy to miss here because the states have &lt;em&gt;names&lt;/em&gt;. &lt;code&gt;half-open&lt;/code&gt; looks like an identity. It is not: it is a label that the circuit wears repeatedly, and comparing labels tells you nothing about whether you are still in the world you made your decision in.&lt;/p&gt;

&lt;h3&gt;
  
  
  My first fix was a patch, and I knew it
&lt;/h3&gt;

&lt;p&gt;I noticed the race before I had a distributed store — the in-process breaker had the same window whenever a custom store was async. So I patched it: after the swap succeeded, check whether the period had flipped, and if it had, &lt;strong&gt;swap the state back&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I even wrote the honest comment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The half-open period this failure belonged to ended while the CAS&lt;/span&gt;
&lt;span class="c1"&gt;// travelled: the trip landed on a FRESH period. Hand the state back&lt;/span&gt;
&lt;span class="c1"&gt;// — best effort until stores can fence the CAS with a generation.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"Best effort" is a confession. Two swaps are not one swap: between them, another instance sees the wrong state, and the compensating swap can itself fail. I shipped it because the alternative was redesigning the store contract, and I wasn't ready.&lt;/p&gt;

&lt;h3&gt;
  
  
  The real fix: identify the period, not the state
&lt;/h3&gt;

&lt;p&gt;The fix is a &lt;strong&gt;fence&lt;/strong&gt; — a monotonic token the store mints on every successful transition:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;readState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;openedAt&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nf"&gt;compareAndSet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fence&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;snapshot&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The swap lands only if the state is still &lt;code&gt;from&lt;/code&gt; &lt;strong&gt;and&lt;/strong&gt; nothing has transitioned since you read that fence. The stale decision from step 4 now carries fence 7 against a store holding fence 10. Redis refuses it, atomically, in the same script. No compensation. No window.&lt;/p&gt;

&lt;p&gt;Both compensating transitions were &lt;strong&gt;deleted&lt;/strong&gt;. The code got shorter, which is how you know the design got better.&lt;/p&gt;

&lt;p&gt;Two details that fell out of it, and that I would not have thought to add:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The failed swap returns where the circuit actually is.&lt;/strong&gt; Losing a race used to cost a second round trip to find out what happened. Now the outcome carries the current snapshot — you lose the race and refresh your view in one shot.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The store owns the timing.&lt;/strong&gt; &lt;code&gt;openedAt&lt;/code&gt; lives in Redis, stamped from the &lt;em&gt;server&lt;/em&gt; clock. Before that, an instance that never saw the trip started counting the cooldown from the moment it first noticed — so instances disagreed about when probing was allowed, and the one that noticed earliest probed too soon. Now they all read the same number.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 2: a promise that never settles is worse than an error
&lt;/h2&gt;

&lt;p&gt;The whole premise of a distributed circuit breaker is that Redis is now on the path of every protected call. Which raises the obvious question: what happens when Redis is down?&lt;/p&gt;

&lt;p&gt;I had an answer I was proud of. &lt;strong&gt;No method of the store ever rejects.&lt;/strong&gt; If Redis is unreachable, the store answers from what the instance already knows and the circuit simply becomes local until Redis comes back. You lose agreement between instances, not the protection itself. A resilience library that fails when its own backend fails has the problem backwards.&lt;/p&gt;

&lt;p&gt;I tested it thoroughly. Store throws → contained. Store rejects → contained. Every method, both paths, all green.&lt;/p&gt;

&lt;p&gt;Then a reviewer asked what happens when the client doesn't throw &lt;em&gt;or&lt;/em&gt; resolve.&lt;/p&gt;

&lt;p&gt;ioredis, with default options — the exact configuration my own documentation recommended — has &lt;code&gt;enableOfflineQueue: true&lt;/code&gt;. When the connection is down, commands are &lt;strong&gt;queued&lt;/strong&gt;, not rejected. They settle after the retry budget runs out. Measured:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;readState against a dead Redis      -&amp;gt; settled after 73151 ms
breaker.execute() with Redis down   -&amp;gt; STILL PENDING after 8000 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seventy-three seconds. And it is worse than it looks, because the degradation logic never engages: the code that decides "Redis is down, go local for 5 seconds" runs in the &lt;code&gt;catch&lt;/code&gt;, and nothing ever reaches the &lt;code&gt;catch&lt;/code&gt;. Every protected call, on every breaker sharing that store, waits.&lt;/p&gt;

&lt;p&gt;My library, whose entire job is to stop a dying dependency from taking your service with it, would have taken your service down with a dying Redis. Not with an error — with silence, which is worse, because nothing times out and nothing gets logged.&lt;/p&gt;

&lt;p&gt;The fix is a bound the store owns rather than inherits:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;race&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;runScript&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;script&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nf"&gt;timeoutAfter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;commandTimeoutMs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// default 500ms&lt;/span&gt;
&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Documenting &lt;code&gt;enableOfflineQueue: false&lt;/code&gt; would not have been enough. The store's central promise cannot depend on wiring it does not control.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the pre-1.0 review actually found
&lt;/h2&gt;

&lt;p&gt;I do not ship a release without a review pass, and for &lt;code&gt;1.0.0&lt;/code&gt; I ran four — core concurrency, the Redis adapter and its Lua, the public API and docs, and security. Between them, &lt;strong&gt;seven serious defects, every one reproduced by execution before I touched anything&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A breaker that, with a store that does not report timing, &lt;strong&gt;stopped protecting permanently&lt;/strong&gt; — &lt;code&gt;nextAttemptAt&lt;/code&gt; landed in the past and every subsequent call was admitted as a probe, forever.&lt;/li&gt;
&lt;li&gt;Probe slots going &lt;strong&gt;negative&lt;/strong&gt;, so more concurrent probes than configured hit a recovering dependency.&lt;/li&gt;
&lt;li&gt;The 73-second stall above.&lt;/li&gt;
&lt;li&gt;An &lt;code&gt;isolate()&lt;/code&gt; kill switch — the one you use to cut off a compromised dependency — that &lt;strong&gt;un-isolated itself&lt;/strong&gt; after a TTL.&lt;/li&gt;
&lt;li&gt;A store taken offline by one slow command that failed &lt;em&gt;after&lt;/em&gt; a newer command had already succeeded, proving Redis was up.&lt;/li&gt;
&lt;li&gt;A float &lt;code&gt;ttlMs&lt;/code&gt; that aborted the Lua script mid-write, leaving keys with &lt;strong&gt;no expiry at all&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;An unvalidated reply that could wedge the circuit open with no way back.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here is the part I want to underline: &lt;strong&gt;several of those were in code I had just written to fix other bugs.&lt;/strong&gt; The self-un-isolating kill switch was a direct consequence of a TTL fix I had applied an hour earlier — renewing the lease on every read, which quietly undid the &lt;code&gt;PERSIST&lt;/code&gt; that made isolation permanent.&lt;/p&gt;

&lt;p&gt;I now treat the fix batch as its own reviewable unit, because that is where a meaningful share of my bugs live. Fixing something is when you are most confident and least careful.&lt;/p&gt;

&lt;p&gt;And then CI found an eighth, after all 379 local checks passed. My command timeout used an unref'd timer — the kind of tidiness that seems obviously correct. In a process with nothing else pending, the event loop drains, the timer never fires, and the caller's promise never resolves. My protection against hanging had a hanging path of its own. It only shows up on a machine quiet enough to notice.&lt;/p&gt;

&lt;h2&gt;
  
  
  What 1.0 actually promises
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;1.0.0&lt;/code&gt; that does not say what it protects is just a number. So the release also ships a &lt;a href="https://github.com/pinceladasdaweb/breakwater/blob/main/docs/versioning.md" rel="noopener noreferrer"&gt;versioning policy&lt;/a&gt; that spells out what semver covers here — and one distinction I had not seen stated elsewhere.&lt;/p&gt;

&lt;p&gt;Most types in a library are ones you &lt;strong&gt;consume&lt;/strong&gt;. Adding a field to those is a minor: your code keeps compiling.&lt;/p&gt;

&lt;p&gt;But four interfaces exist for you to &lt;strong&gt;implement&lt;/strong&gt; — the state store, the cache store, the metrics collector, the Redis client boundary. For those, the direction reverses: &lt;strong&gt;adding a required member is a breaking change&lt;/strong&gt;, because your implementation suddenly no longer satisfies the interface. So on those four, new capabilities arrive as optional members, and a member only becomes required in a major.&lt;/p&gt;

&lt;p&gt;That rule cost one member its place before the freeze. &lt;code&gt;StateStore.subscribe&lt;/code&gt; was declared for a push-based invalidation that nothing called yet. An optional method the library never invokes is a promise you are not keeping — somebody implements it and waits for calls that never come. It came out. It returns when there is code behind it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;breakwater ioredis
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Redis&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ioredis&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;circuitBreaker&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;breakwater&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;redisStore&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fromIoredis&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;breakwater/redis&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Redis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;REDIS_URL&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;redis://127.0.0.1:6379&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;redisStore&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;fromIoredis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payments&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;circuitBreaker&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;payments-api&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// the key the circuit is shared under&lt;/span&gt;
  &lt;span class="na"&gt;stateStore&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;failureThreshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;minimumCalls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;receipt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;payments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(({&lt;/span&gt; &lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/charge&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole integration. Composition, events, and the Prometheus and OpenTelemetry adapters work exactly as before — only where the state lives has changed.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitHub&lt;/strong&gt;: &lt;a href="https://github.com/pinceladasdaweb/breakwater" rel="noopener noreferrer"&gt;pinceladasdaweb/breakwater&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;npm&lt;/strong&gt;: &lt;a href="https://www.npmjs.com/package/breakwater" rel="noopener noreferrer"&gt;breakwater&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Redis page&lt;/strong&gt;, including everything a shared circuit &lt;em&gt;cannot&lt;/em&gt; promise: &lt;a href="https://github.com/pinceladasdaweb/breakwater/blob/main/docs/redis.md" rel="noopener noreferrer"&gt;docs/redis.md&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you are building anything that shares state across instances, the lesson worth stealing is the first one: &lt;strong&gt;a compare-and-set on a value that repeats is not a compare-and-set.&lt;/strong&gt; Fence it, or you are trusting a name to be an identity.&lt;/p&gt;

</description>
      <category>node</category>
      <category>distributedsystems</category>
      <category>typescript</category>
      <category>opensource</category>
    </item>
    <item>
      <title>My test suite had 100% coverage. Mutation testing still found real bugs.</title>
      <dc:creator>Pedro Rogério</dc:creator>
      <pubDate>Mon, 03 Aug 2026 12:36:49 +0000</pubDate>
      <link>https://dev.to/pinceladasdaweb/my-test-suite-had-100-coverage-mutation-testing-still-found-real-bugs-2man</link>
      <guid>https://dev.to/pinceladasdaweb/my-test-suite-had-100-coverage-mutation-testing-still-found-real-bugs-2man</guid>
      <description>&lt;p&gt;&lt;a href="https://github.com/pinceladasdaweb/breakwater" rel="noopener noreferrer"&gt;breakwater&lt;/a&gt; is my resilience toolkit for Node.js — retry, circuit breaker, timeout, bulkhead, rate limiting, all composable, with observability built in. It's the library that stands between your service and a dying dependency, which means its tests are not decoration. If the circuit breaker's threshold logic is wrong, somebody's incident gets worse.&lt;/p&gt;

&lt;p&gt;The suite had &lt;strong&gt;100% line coverage&lt;/strong&gt; on the core. Every branch tool was green. I was feeling pretty good about it.&lt;/p&gt;

&lt;p&gt;Then I ran &lt;a href="https://stryker-mutator.io/" rel="noopener noreferrer"&gt;Stryker&lt;/a&gt; and it graded my suite at &lt;strong&gt;80.52%&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Coverage measures execution. Mutation measures assertion.
&lt;/h2&gt;

&lt;p&gt;If you haven't used mutation testing: the tool takes your source, makes one small change at a time — flips a &lt;code&gt;&amp;gt;=&lt;/code&gt; to &lt;code&gt;&amp;gt;&lt;/code&gt;, replaces a condition with &lt;code&gt;true&lt;/code&gt;, empties a block — and runs your suite against each &lt;em&gt;mutant&lt;/em&gt;. If some test fails, the mutant is killed. If everything stays green, it &lt;strong&gt;survived&lt;/strong&gt;: your tests executed that line and asserted nothing about it.&lt;/p&gt;

&lt;p&gt;Coverage tells you the line ran. Mutation testing tells you whether anyone would notice if the line were wrong. Those are very different claims, and the gap between them is exactly where production bugs live.&lt;/p&gt;

&lt;p&gt;Stryker generated 1,114 mutants from my source. &lt;strong&gt;199 survived.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What the survivors actually were
&lt;/h2&gt;

&lt;p&gt;I went through every single one. They fell into five buckets, and each bucket taught me something different.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. A real bug, in production, that day
&lt;/h3&gt;

&lt;p&gt;A cluster of survivors pointed at the code that injects a registry name into policy options. Writing the test that should have killed them uncovered an actual defect: a named policy entry with an explicit display name reported the pipeline under one name and its rate limiter under &lt;strong&gt;another&lt;/strong&gt; — the same policy showing up as two series in a metrics dashboard, in direct contradiction of the documented "explicit names always win" rule.&lt;/p&gt;

&lt;p&gt;That fix shipped to npm the same week. No user had reported it. Coverage had been green the whole time.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. A test that was lying about what it tested
&lt;/h3&gt;

&lt;p&gt;One test existed specifically to pin a floating-point rounding fix: &lt;code&gt;retryAfterMs&lt;/code&gt; must always be &lt;em&gt;sufficient&lt;/em&gt; — wait exactly that long and you're admitted. Its comment proudly said the configuration reproduced a case where the naive &lt;code&gt;ceil()&lt;/code&gt; lands one millisecond short.&lt;/p&gt;

&lt;p&gt;Except it didn't anymore. The configuration (&lt;code&gt;interval: 8737&lt;/code&gt;) no longer triggered the correction loop at all — the test passed with or without the code it claimed to protect. I searched the parameter space for a configuration that &lt;em&gt;actually&lt;/em&gt; hits the rounding error (&lt;code&gt;interval: 161&lt;/code&gt;, if you're curious: &lt;code&gt;161 * (1/161)&lt;/code&gt; is &lt;code&gt;0.999…&lt;/code&gt;) and rewrote the test around it.&lt;/p&gt;

&lt;p&gt;A test that asserts something true but unrelated to its purpose is worse than no test: it's documentation that lies.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Documented contracts nobody ever asserted
&lt;/h3&gt;

&lt;p&gt;The biggest bucket. Things the README and docs promised, that all worked, and that no test would defend against regression:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The circuit breaker's &lt;code&gt;open&lt;/code&gt;, &lt;code&gt;close&lt;/code&gt; and &lt;code&gt;halfOpen&lt;/code&gt; events — only the generic &lt;code&gt;stateChange&lt;/code&gt; was ever checked. Payloads, correlation IDs, one-event-per-transition: all unpinned.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every distributed state-store path.&lt;/strong&gt; The breaker has a pluggable &lt;code&gt;StateStore&lt;/code&gt; designed for sharing circuit state across instances — stores that answer asynchronously, losing a compare-and-set race to a peer, another instance winning the half-open probe election. The interface existed, the local implementation was tested, and not one distributed behavior had a test.&lt;/li&gt;
&lt;li&gt;Boundary semantics: a failure rate landing &lt;em&gt;exactly&lt;/em&gt; on the threshold, a retry delay landing &lt;em&gt;exactly&lt;/em&gt; on the deadline.&lt;/li&gt;
&lt;li&gt;Jitter that actually &lt;em&gt;spreads&lt;/em&gt;. My tests asserted delays stayed within &lt;code&gt;[0, max]&lt;/code&gt; — a jitter implementation returning a constant would have passed while doing nothing about thundering herds.&lt;/li&gt;
&lt;li&gt;A metrics collector implementing none of its optional callbacks. This one hid behind deliberate error-swallowing (monitoring must never break execution), so the fix asserts the error reporter stays silent too.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  4. Dead code — the report doubles as a detector
&lt;/h3&gt;

&lt;p&gt;Some survivors survived because &lt;strong&gt;no input can reach them&lt;/strong&gt;. Two examples from the timeout policy: a guard in the timer callback protecting an assignment that nothing could ever read (the catch block re-checks the same condition first, and signals never un-abort), and one arm of a condition that was provably always false at its only call site.&lt;/p&gt;

&lt;p&gt;I didn't take "provably" on faith — I rebuilt the old version and ran a differential matrix across cooperative/aggressive modes, abort orderings and rejection identities. Zero divergence. Both paths deleted. The invariant they pretended to guard now lives in exactly one place, with a test pinning its sharpest corner: an external cancellation that lands &lt;em&gt;after&lt;/em&gt; the deadline fired still wins, and the timeout event stays silent.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Equivalent mutants — know when to stop
&lt;/h3&gt;

&lt;p&gt;Not everything should be killed. &lt;code&gt;{ once: true }&lt;/code&gt; on an &lt;code&gt;abort&lt;/code&gt; listener is unkillable by any black-box test, because an &lt;code&gt;AbortSignal&lt;/code&gt; fires &lt;code&gt;abort&lt;/code&gt; at most once in its lifetime — the mutant is behaviorally identical. Same for a fast-path that skips building a composite signal: pure allocation optimization.&lt;/p&gt;

&lt;p&gt;Chasing those means asserting listener counts and internal allocations — testing implementation, which is the same disease as coverage-theater in the opposite direction. I documented each accepted survivor and moved on. The honest end state isn't 100%: it's &lt;strong&gt;every survivor analyzed and either killed or justified&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Final score: &lt;strong&gt;95.11%&lt;/strong&gt;, with the suite growing from 157 to 223 tests. And one bonus find that wasn't a mutant at all: two of my new tests would have &lt;strong&gt;hung CI forever instead of failing&lt;/strong&gt; if their behavior regressed, because &lt;code&gt;node:test&lt;/code&gt; has no default timeout. If you use the built-in runner, set one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"test"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"node --import tsx --test --test-timeout=10000 'tests/**/*.test.ts'"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The setup, if you want this on node:test
&lt;/h2&gt;

&lt;p&gt;Stryker's tap runner drives the native runner just fine, TypeScript included:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"testRunner"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tap"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tap"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"testFiles"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"tests/**/*.test.ts"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"nodeArgs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"--import"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tsx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"--test"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"--test-reporter=tap"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"--experimental-test-isolation=none"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mutate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"src/**/*.ts"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"coverageAnalysis"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"perTest"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reporters"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"html"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"progress"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"timeoutMS"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;20000&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;coverageAnalysis: "perTest"&lt;/code&gt; matters — it runs only the tests covering each mutant, which took a full run of my suite to under six minutes. I keep it as a manual gate (&lt;code&gt;npm run test:mutation&lt;/code&gt;) rather than a CI job: it's a code-review tool, not a merge blocker.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I did all this before shipping observability
&lt;/h2&gt;

&lt;p&gt;Because the newest release of breakwater is &lt;strong&gt;an observability feature&lt;/strong&gt;, and shipping a tool that watches production on top of tests I couldn't trust felt absurd.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;breakwater@0.7.0&lt;/code&gt; adds &lt;code&gt;breakwater/prometheus&lt;/code&gt; — ready-made &lt;a href="https://github.com/siimon/prom-client" rel="noopener noreferrer"&gt;prom-client&lt;/a&gt; collectors for every signal the library emits:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;resilience&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;breakwater&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;prometheusCollector&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;breakwater/prometheus&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payments&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;resilience&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;payments-api&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;retry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;attempts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;circuitBreaker&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;consecutiveFailures&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;metrics&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;prometheusCollector&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole integration. Eight metrics come out: executions and a duration histogram by outcome, retries, timeouts, fallbacks, rejections by reason (&lt;code&gt;circuit_open&lt;/code&gt;, &lt;code&gt;bulkhead_full&lt;/code&gt;, &lt;code&gt;rate_limited&lt;/code&gt;…), the circuit state as an enum gauge, and a transitions counter. &lt;code&gt;prom-client&lt;/code&gt; is an optional peer dependency — the core keeps its zero runtime dependencies, and importing plain &lt;code&gt;breakwater&lt;/code&gt; never loads it.&lt;/p&gt;

&lt;p&gt;Two design notes that came straight out of caring about honest observability:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Labels are low-cardinality by construction.&lt;/strong&gt; Correlation IDs and attempt numbers never become label values. The only free-form label is your policy's &lt;code&gt;name&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Absence means healthy.&lt;/strong&gt; The state gauge is event-driven, so a circuit that has never tripped exports no state series yet. The docs say so explicitly, and the suggested alert targets the gauge (&lt;code&gt;breakwater_circuit_state{state="open"} == 1&lt;/code&gt;) instead of &lt;code&gt;increase()&lt;/code&gt; on a transitions counter — a counter series born by its first-ever transition needs two samples before &lt;code&gt;increase()&lt;/code&gt; sees anything, which is exactly how you miss your first incident.&lt;/p&gt;

&lt;p&gt;There's a ready-to-import &lt;a href="https://github.com/pinceladasdaweb/breakwater/blob/main/examples/grafana/breakwater-dashboard.json" rel="noopener noreferrer"&gt;Grafana dashboard&lt;/a&gt; covering all eight metrics, and a &lt;a href="https://github.com/pinceladasdaweb/breakwater/tree/main/examples/prometheus-grafana" rel="noopener noreferrer"&gt;runnable demo&lt;/a&gt; — &lt;code&gt;docker compose up&lt;/code&gt; brings up a breakwater-protected app with a scripted outage every 90 seconds, Prometheus scraping it, and Grafana already provisioned. You get to watch the circuit open, fallbacks take over, and a half-open probe close the loop, on a real dashboard, without wiring anything.&lt;/p&gt;

&lt;p&gt;And yes: the adapter's module was born under the mutation gate. First report: &lt;strong&gt;100% — zero survivors.&lt;/strong&gt; It turns out writing honest tests is much easier when you do it from the start.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Coverage is a floor, not a verdict.&lt;/strong&gt; 100% coverage coexisted with 199 unasserted behaviors in my suite.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read every survivor.&lt;/strong&gt; The value isn't the score — it's the taxonomy: real bugs, lying tests, unpinned contracts, dead code, and equivalents you consciously accept.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mutation reports find dead code&lt;/strong&gt; that no linter flags, because reachability is a semantic property.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set a test timeout&lt;/strong&gt; with &lt;code&gt;node:test&lt;/code&gt;. A test that hangs instead of failing is a CI outage waiting for a regression.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't chase 100%.&lt;/strong&gt; Killing equivalent mutants means testing internals. Analyze, justify, stop.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;breakwater is on &lt;a href="https://github.com/pinceladasdaweb/breakwater" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; and &lt;a href="https://www.npmjs.com/package/breakwater" rel="noopener noreferrer"&gt;npm&lt;/a&gt; — MIT, docs for every policy, and the comparison with opossum/cockatiel is in the README. If you run it behind Prometheus, I'd genuinely like to hear what your dashboards catch.&lt;/p&gt;

</description>
      <category>node</category>
      <category>testing</category>
      <category>typescript</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Node.js has plenty of circuit breakers. So why did I build another one?</title>
      <dc:creator>Pedro Rogério</dc:creator>
      <pubDate>Mon, 27 Jul 2026 12:40:00 +0000</pubDate>
      <link>https://dev.to/pinceladasdaweb/nodejs-has-plenty-of-circuit-breakers-so-why-did-i-build-another-one-3k66</link>
      <guid>https://dev.to/pinceladasdaweb/nodejs-has-plenty-of-circuit-breakers-so-why-did-i-build-another-one-3k66</guid>
      <description>&lt;p&gt;Every service I've worked on eventually grows the same scar tissue: a retry loop copy-pasted into six files, a circuit breaker bolted onto the payment client after an outage, a timeout wrapper someone wrote at 3 a.m. Each one slightly different. None of them talking to each other. And when things go wrong, nobody can answer the only question that matters during an incident: &lt;strong&gt;what is the resilience layer actually doing right now?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Java solved this years ago with &lt;a href="https://resilience4j.readme.io/" rel="noopener noreferrer"&gt;resilience4j&lt;/a&gt;. .NET has &lt;a href="https://github.com/App-vNext/Polly" rel="noopener noreferrer"&gt;Polly&lt;/a&gt;. Node.js... has pieces.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gap
&lt;/h2&gt;

&lt;p&gt;I evaluated what the ecosystem offers before writing a single line:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/nodeshift/opossum" rel="noopener noreferrer"&gt;opossum&lt;/a&gt;&lt;/strong&gt; is the best-known circuit breaker, mature and well maintained. But it's &lt;em&gt;only&lt;/em&gt; a circuit breaker — retry is rudimentary, there's no bulkhead, no composition. Metrics need a plugin.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/connor4312/cockatiel" rel="noopener noreferrer"&gt;cockatiel&lt;/a&gt;&lt;/strong&gt; is the closest thing to Polly: retry, breaker, timeout, bulkhead, composition. I genuinely like its design. But observability is where it stops — no native metrics, no pipeline-wide correlation — and maintenance has slowed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Sindre micro-libs&lt;/strong&gt; (&lt;code&gt;p-retry&lt;/code&gt;, &lt;code&gt;p-timeout&lt;/code&gt;, &lt;code&gt;p-limit&lt;/code&gt;) are excellent at exactly one thing each. But resilience is a &lt;em&gt;system&lt;/em&gt;: a retry that doesn't know the circuit is open will happily sleep through backoff to hammer a dead dependency. Isolated pieces can't coordinate.&lt;/p&gt;

&lt;p&gt;And there was one thing &lt;strong&gt;nobody&lt;/strong&gt; documented properly, which became the reason I finally started typing:&lt;/p&gt;

&lt;h2&gt;
  
  
  Ordering is the whole game
&lt;/h2&gt;

&lt;p&gt;Take four policies: retry, circuit breaker, timeout, fallback. The same four, nested in two different orders, produce two very different systems:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;retry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;circuitBreaker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nx"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// A&lt;/span&gt;
&lt;span class="nf"&gt;circuitBreaker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;retry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nx"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// B&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In &lt;strong&gt;A&lt;/strong&gt;, every attempt flows through the breaker, so the breaker sees the dependency's &lt;em&gt;true&lt;/em&gt; failure rate — and when the circuit opens mid-retry, the retry finds out immediately.&lt;/p&gt;

&lt;p&gt;In &lt;strong&gt;B&lt;/strong&gt;, the breaker sees one outcome per retry &lt;em&gt;cycle&lt;/em&gt;: three real failures against the dependency count as a single failure. The circuit opens far later than the dependency's actual state justifies, and the retry keeps sleeping through backoff against a broker that's clearly down.&lt;/p&gt;

&lt;p&gt;Most libraries let you build either one without ever telling you there's a difference. That's not an API problem — it's a documentation-of-consequences problem. I wanted a library where &lt;strong&gt;composition and its consequences are the headline feature&lt;/strong&gt;, not an afterthought.&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://github.com/pinceladasdaweb/breakwater" rel="noopener noreferrer"&gt;breakwater&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it looks like
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;breakwater
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;resilience&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;exponential&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;breakwater&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payments&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;resilience&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;retry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;attempts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;backoff&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;exponential&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;initial&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;rateLimit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;bulkhead&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;concurrency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;circuitBreaker&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;payments-api&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;failureThreshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pending&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;queued&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;charge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;payments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(({&lt;/span&gt; &lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/charge&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;signal&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;resilience()&lt;/code&gt; is the batteries-included path with a fixed, documented order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;retry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;rateLimit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;bulkhead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;circuitBreaker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nx"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each position is deliberate. The local guards (rate limit, bulkhead) sit &lt;em&gt;outside&lt;/em&gt; the breaker, because your own saturation must never open a circuit that describes the &lt;em&gt;dependency's&lt;/em&gt; health. The timeout sits innermost so every attempt gets its own budget — and a hung call becomes a countable failure instead of an invisible one.&lt;/p&gt;

&lt;p&gt;Need a different order? &lt;code&gt;compose()&lt;/code&gt; reads exactly like the nested calls:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;compose&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;retry&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;circuitBreaker&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;timeout&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;breakwater&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;policy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;compose&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nf"&gt;retry&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;attempts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;          &lt;span class="c1"&gt;// outermost&lt;/span&gt;
  &lt;span class="nf"&gt;circuitBreaker&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;api&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                   &lt;span class="c1"&gt;// innermost&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And because the result of &lt;code&gt;compose()&lt;/code&gt; is itself a policy, compositions compose again. The &lt;a href="https://github.com/pinceladasdaweb/breakwater/blob/main/docs/composition.md" rel="noopener noreferrer"&gt;ordering guide&lt;/a&gt; walks through the classic configurations with sequence diagrams — it's the page I wish every resilience library had.&lt;/p&gt;

&lt;h2&gt;
  
  
  Details that only show up in production
&lt;/h2&gt;

&lt;p&gt;A few behaviors took far more design effort than any diagram suggests, because they only matter when things are already going wrong:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Policies coordinate through errors.&lt;/strong&gt; Every error carries a stable &lt;code&gt;code&lt;/code&gt; and a &lt;code&gt;retryable&lt;/code&gt; flag. &lt;code&gt;CircuitOpenError&lt;/code&gt; declares itself &lt;code&gt;retryable: false&lt;/code&gt; — so the default retry gives up instantly instead of backing off against an open circuit. Rate-limit and bulkhead rejections stay retryable, because saturation is transient. This is why the pieces compose correctly &lt;em&gt;out of the box&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cancellation is not failure.&lt;/strong&gt; One &lt;code&gt;AbortSignal&lt;/code&gt; — combining your external cancellation, timeouts, everything — reaches your function. And when the caller aborts, nothing counts it as a failure: retry doesn't retry it, the breaker doesn't tally it, fallback doesn't replace it. Your user closing a tab should never open a circuit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Timeouts don't lie.&lt;/strong&gt; A genuine domain error that lands after the deadline propagates as itself, never masked as a &lt;code&gt;TimeoutError&lt;/code&gt;. A call the caller cancelled is cancellation, not a timeout. Your error dashboards reflect what actually happened.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Monitoring never changes an outcome.&lt;/strong&gt; An event listener or metrics collector that throws is isolated and reported — a bug in your telemetry cannot turn a successful payment into an error.&lt;/p&gt;

&lt;h2&gt;
  
  
  Observability without plugins
&lt;/h2&gt;

&lt;p&gt;Every policy emits typed events, one &lt;code&gt;correlationId&lt;/code&gt; crosses the whole pipeline, and stateful policies expose &lt;code&gt;stats()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;breaker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stats&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="c1"&gt;// { state: 'open', failureRate: 0.85, lastError, openedAt, nextAttemptAt, ... }&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;CircuitOpenError&lt;/code&gt; carries that snapshot too — a proper &lt;code&gt;Retry-After&lt;/code&gt; header is three lines. For metrics pipelines, implement one &lt;code&gt;MetricsCollector&lt;/code&gt; interface and it wires everywhere; define your policies once in a named registry and every metric comes out labeled:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;policies&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;breakwater&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="nx"&gt;policies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;payments-api&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;retry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;attempts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="na"&gt;circuitBreaker&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="c1"&gt;// anywhere else — same name, same instance, genuinely shared circuit state&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;policies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;payments-api&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(({&lt;/span&gt; &lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/charge&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A health endpoint over every policy in your app is a &lt;code&gt;policies.names().map(...)&lt;/code&gt; away.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dogfooding, honestly
&lt;/h2&gt;

&lt;p&gt;I maintain a &lt;a href="https://github.com/pinceladasdaweb/rabbitmq" rel="noopener noreferrer"&gt;RabbitMQ client library&lt;/a&gt; that had its own hand-rolled circuit breaker and retry loop — the exact scar tissue this post opened with. Migrating it to breakwater deleted the bespoke resilience code, upgraded the publisher to the correct retry-outside-the-breaker ordering, and &lt;strong&gt;every one of its existing tests passed unchanged&lt;/strong&gt;, including destructive reconnection tests against a real broker. That migration shipped; it's running in production today.&lt;/p&gt;

&lt;p&gt;It also taught me things no unit test would: that consumers of a hand-rolled retry expect the raw last error (there's a documented unwrap pattern now), and that sync call sites need a recreate-the-policy idiom for breaker resets. Both learnings went straight into the docs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it is — and isn't (yet)
&lt;/h2&gt;

&lt;p&gt;breakwater today: retry (backoff strategies + jitter + total deadline), timeout (cooperative and aggressive), circuit breaker (sliding windows, bounded half-open probing, manual isolation), bulkhead, rate limiting (token bucket and exact sliding window), chained fallbacks, composition, typed events, metrics, named policies. Zero runtime dependencies, TypeScript-native, dual ESM/CJS, Node &amp;gt;= 22.&lt;/p&gt;

&lt;p&gt;Planned and designed for (the circuit breaker's state store is already pluggable): &lt;strong&gt;distributed circuit breaker state over Redis&lt;/strong&gt; — ten instances of your service agreeing that an endpoint is down, with a single instance elected to probe recovery. Plus ready-made Prometheus/OpenTelemetry collectors and a stale-while-open response cache.&lt;/p&gt;

&lt;p&gt;The API is heading to 1.0; until then, minor versions may adjust it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitHub&lt;/strong&gt;: &lt;a href="https://github.com/pinceladasdaweb/breakwater" rel="noopener noreferrer"&gt;github.com/pinceladasdaweb/breakwater&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;npm&lt;/strong&gt;: &lt;a href="https://www.npmjs.com/package/breakwater" rel="noopener noreferrer"&gt;npmjs.com/package/breakwater&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Start here&lt;/strong&gt;: the &lt;a href="https://github.com/pinceladasdaweb/breakwater/blob/main/docs/composition.md" rel="noopener noreferrer"&gt;composition &amp;amp; ordering guide&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've ever debugged a retry storm at 3 a.m., I'd genuinely love your feedback — issues and PRs welcome. And if breakwater saves you from writing one more bespoke circuit breaker, it did its job.&lt;/p&gt;

</description>
      <category>node</category>
      <category>typescript</category>
      <category>webdev</category>
      <category>opensource</category>
    </item>
    <item>
      <title>My Redis library said the write succeeded. Redis was down.</title>
      <dc:creator>Pedro Rogério</dc:creator>
      <pubDate>Fri, 24 Jul 2026 12:59:40 +0000</pubDate>
      <link>https://dev.to/pinceladasdaweb/my-redis-library-said-the-write-succeeded-redis-was-down-3dmn</link>
      <guid>https://dev.to/pinceladasdaweb/my-redis-library-said-the-write-succeeded-redis-was-down-3dmn</guid>
      <description>&lt;p&gt;Last year I &lt;a href="https://dev.to/pinceladasdaweb/my-rabbitmq-library-looked-production-ready-it-couldnt-survive-a-single-reconnect-1lp"&gt;audited my RabbitMQ library&lt;/a&gt; and discovered it couldn't survive a single reconnect. I turned the whole ordeal into a playbook, and I promised myself I'd run it against the next library in my drawer.&lt;/p&gt;

&lt;p&gt;The next library was a Redis client I wrote two years ago. The README advertised connection pooling, pub/sub, distributed locking, "battle-tested error handling" and "production-ready resilience".&lt;/p&gt;

&lt;p&gt;Here's what the audit found: there was no pooling. There was no pub/sub. There was no locking. There were no tests. And the resilience — the one thing a Redis wrapper exists for — was worse than absent. It was &lt;em&gt;actively lying&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;This is the story of the second time I learned the same lesson, plus the parts that were new.&lt;/p&gt;

&lt;h2&gt;
  
  
  The write that succeeded by doing nothing
&lt;/h2&gt;

&lt;p&gt;Every command in the library went through a health check. If Redis was unhealthy, this happened:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isHealthy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Redis is not healthy. Skipping &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;command&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; operation.`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that again. &lt;code&gt;set&lt;/code&gt;, &lt;code&gt;incr&lt;/code&gt;, &lt;code&gt;lpush&lt;/code&gt; — writes — would &lt;strong&gt;resolve successfully having done nothing&lt;/strong&gt;. No throw, no rejection. Your checkout flow calls &lt;code&gt;set&lt;/code&gt;, gets a resolved promise, tells the user their cart is saved. The cart does not exist.&lt;/p&gt;

&lt;p&gt;The best part? The README documented this as a feature: &lt;em&gt;"Methods return null when Redis is not healthy."&lt;/em&gt; I had documented data loss and called it graceful degradation.&lt;/p&gt;

&lt;p&gt;And for reads it's worse in a subtler way: &lt;code&gt;get&lt;/code&gt; returning &lt;code&gt;null&lt;/code&gt; means "key doesn't exist" — or now, "Redis was down". Your cache layer can't tell a miss from an outage.&lt;/p&gt;

&lt;p&gt;The fix is a contract, not a patch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RedisClientError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s2"&gt;`Redis is not connected. Cannot execute '&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;command&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;'.`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;command&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;REDIS_UNAVAILABLE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Structured errors with a &lt;code&gt;code&lt;/code&gt;. The caller branches on &lt;code&gt;err.code === 'REDIS_UNAVAILABLE'&lt;/code&gt;, never on message text. A write that can't happen must be loud. This was a breaking change — and it's exactly why I fixed it &lt;em&gt;before&lt;/em&gt; the library had public users. Defaults are free to change until the day someone depends on them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reconnection layer that fought the driver — and lost to itself
&lt;/h2&gt;

&lt;p&gt;My library sits on top of ioredis. ioredis already reconnects. It has &lt;code&gt;retryStrategy&lt;/code&gt;, exponential backoff, an offline queue, resubscription. It is &lt;em&gt;good&lt;/em&gt; at this.&lt;/p&gt;

&lt;p&gt;Two-years-ago me didn't trust it, so he built a second reconnection layer on top. Every &lt;code&gt;error&lt;/code&gt; or &lt;code&gt;close&lt;/code&gt; event scheduled a manual reconnect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;scheduleReconnect &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reconnectAttempts&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;
  &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;attemptConnection&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reconnectInterval&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;attemptConnection &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;redisConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createRedisClient&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;// ← a NEW client&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Spot it? Every attempt created a &lt;strong&gt;brand-new ioredis client without destroying the previous one&lt;/strong&gt;. The old client — with its own retryStrategy — kept reconnecting forever in the background. Every drop leaked one more client, each running its own infinite retry loop against your server. A one-minute Redis outage turned my "resilience layer" into a connection storm.&lt;/p&gt;

&lt;p&gt;It gets better. After the first attempt, the validation ping was skipped entirely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;initialConnectionAttempted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ping&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;initialConnectionAttempted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// every reconnection after this point:&lt;/span&gt;
&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isConnected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;   &lt;span class="c1"&gt;// sure, why not&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every reconnection "succeeded" unconditionally. &lt;code&gt;isConnected = true&lt;/code&gt; while Redis was on fire. The reconnection had &lt;strong&gt;never worked and never been tested&lt;/strong&gt; — the exact disease from the RabbitMQ library, in a different body.&lt;/p&gt;

&lt;p&gt;And my favorite: &lt;code&gt;disconnect()&lt;/code&gt; called &lt;code&gt;quit()&lt;/code&gt;, which made ioredis emit &lt;code&gt;close&lt;/code&gt;… and the &lt;code&gt;close&lt;/code&gt; handler scheduled a reconnect. &lt;strong&gt;Graceful shutdown resurrected the connection.&lt;/strong&gt; The library refused to die. There's something poetic about a resilience layer whose only reliably working feature is refusing to let you leave.&lt;/p&gt;

&lt;p&gt;The fix was not code. It was &lt;em&gt;deletion&lt;/em&gt;. The entire manual layer went away — the driver is the single owner of reconnection, and my library only tracks state and emits events. Four critical bugs died in one commit that removed more lines than it added.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test that earns the word "resilient"
&lt;/h2&gt;

&lt;p&gt;From the RabbitMQ refactor I kept one ritual: no reliability claim without a test that kills the connection &lt;em&gt;for real&lt;/em&gt;, from the &lt;em&gt;server side&lt;/em&gt;. For Redis, that's &lt;code&gt;CLIENT KILL&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sacred:before&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;// works&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;killAllClientsFromTheServer&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;               &lt;span class="c1"&gt;// CLIENT KILL, server-side&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;waitFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sacred:after&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;OK&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="c1"&gt;// works again, same client&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plus the two regression probes that document the old bugs forever: after recovery, the server must see &lt;strong&gt;exactly the same number of connections&lt;/strong&gt; as before (no leaked reconnection loops), and after &lt;code&gt;disconnect()&lt;/code&gt; the client must &lt;strong&gt;stay disconnected&lt;/strong&gt; (no resurrection).&lt;/p&gt;

&lt;p&gt;First run against the old code: the resurrection probe failed exactly as predicted, and the test runner never exited — leaked timers kept the process alive. The suite needed Ctrl-C. When your test suite can't die, your library can't either.&lt;/p&gt;

&lt;p&gt;After the surgery: everything green, the runner exits by itself, and recovery after a server-side kill went from ~2 seconds to ~110 milliseconds — because reacting to the driver's events beats waiting for your own arbitrary timer.&lt;/p&gt;

&lt;h2&gt;
  
  
  A parade of small lies
&lt;/h2&gt;

&lt;p&gt;The deep read found plenty more. A sample:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The scan that read other people's keys.&lt;/strong&gt; &lt;code&gt;getAllStream()&lt;/code&gt; used ioredis' &lt;code&gt;scanStream&lt;/code&gt; — which does &lt;em&gt;not&lt;/em&gt; apply your &lt;code&gt;keyPrefix&lt;/code&gt; to the &lt;code&gt;MATCH&lt;/code&gt; pattern. With a prefix configured, scanning for &lt;code&gt;user:*&lt;/code&gt; matched &lt;strong&gt;zero&lt;/strong&gt; of your own keys. And scanning &lt;code&gt;*&lt;/code&gt; swept the &lt;em&gt;entire database&lt;/em&gt;, including other applications' keys, which then got your prefix wrongly added on the follow-up &lt;code&gt;GET&lt;/code&gt; and came back as &lt;code&gt;null&lt;/code&gt;. The old code had mysterious logging for these "null values", fetching TTL and type for each one. That wasn't observability. That was a bug filing reports about itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;maxRetryAttempts: 0&lt;/code&gt; meant infinity.&lt;/strong&gt; The classic &lt;code&gt;options.x || default&lt;/code&gt; — zero is falsy, so "never retry" became "retry forever". Same for &lt;code&gt;block: 0&lt;/code&gt; in stream reads, which in Redis means "block forever" and was silently dropped. &lt;code&gt;??&lt;/code&gt; exists. Use it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;XGROUP DESTROY&lt;/code&gt; couldn't work.&lt;/strong&gt; The wrapper appended a default stream id to every subcommand, so the destroy documented in my own README sent a stray &lt;code&gt;$&lt;/code&gt; and died with a protocol error. The README showed example code that had never once executed successfully.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every command paid a toll.&lt;/strong&gt; The health check ran a real &lt;code&gt;PING&lt;/code&gt; — serialized &lt;em&gt;before&lt;/em&gt; your command, up to once per five seconds. Fail-fast checks must be local and free (&lt;code&gt;client.status === 'ready'&lt;/code&gt;); the driver already owns the expensive part.&lt;/p&gt;

&lt;p&gt;None of these are exotic. They're what the &lt;em&gt;first test you ever write&lt;/em&gt; catches. There were no tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then I had to earn the README
&lt;/h2&gt;

&lt;p&gt;Deleting fiction from the README was easy. But two of the fictional features were genuinely good ideas — so this time they got built for real, with the reliability ritual applied from day one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pub/sub&lt;/strong&gt; lives on a dedicated connection (a subscribed Redis connection can't run normal commands — the library manages that for you), and its sacred test kills the subscriber server-side and proves messages flow again after recovery.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Distributed locking&lt;/strong&gt; is &lt;code&gt;SET NX PX&lt;/code&gt; with a random token, and release/extend go through Lua scripts that check the token — a holder whose lock expired can never delete the new holder's lock. The README says, in bold, that this is a single-instance mutex and &lt;strong&gt;not Redlock&lt;/strong&gt;. Honesty is a feature.&lt;/p&gt;

&lt;p&gt;And they compose. The cache helper uses the lock internally for stampede protection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;report&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getOrSetJson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;report:daily&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;buildReport&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;lock&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ten concurrent cache misses, one producer call — the test proves exactly that, with real concurrency against a real Redis.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reviews that found what I couldn't
&lt;/h2&gt;

&lt;p&gt;"Looking finished" is still the most dangerous state, so finished code got adversarial reviews — every finding had to be &lt;em&gt;reproduced live&lt;/em&gt; before I accepted it. Two highlights that survived the process:&lt;/p&gt;

&lt;p&gt;A producer returning &lt;code&gt;undefined&lt;/code&gt; in the cache helper &lt;strong&gt;poisoned the key&lt;/strong&gt;: &lt;code&gt;JSON.stringify(undefined)&lt;/code&gt; is &lt;code&gt;undefined&lt;/code&gt;, which got stored as an empty string with a TTL — and every later read exploded with &lt;code&gt;SyntaxError&lt;/code&gt; until it expired. One absent-minded &lt;code&gt;return&lt;/code&gt; and the key is bricked for an hour.&lt;/p&gt;

&lt;p&gt;And the packaging check — install the actual tarball in a clean project — caught two defects no unit test could: my &lt;code&gt;prepare&lt;/code&gt; script made npm v12 nag every consumer with an &lt;code&gt;allow-scripts&lt;/code&gt; approval warning, and TypeScript consumers in CommonJS hit &lt;code&gt;TS1479&lt;/code&gt; because a &lt;code&gt;"type": "module"&lt;/code&gt; package needs &lt;em&gt;dual&lt;/em&gt; declaration files (&lt;code&gt;index.d.ts&lt;/code&gt; + &lt;code&gt;index.d.cts&lt;/code&gt;). Validate the artifact, not the repo.&lt;/p&gt;

&lt;h2&gt;
  
  
  What shipped
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;50 tests: 24 unit, 26 integration against a real Redis — including server-side &lt;code&gt;CLIENT KILL&lt;/code&gt; recovery for both commands and pub/sub&lt;/li&gt;
&lt;li&gt;1 runtime dependency (ioredis). The old version had three: pino and pino-pretty shipped a dev log formatter to every production install. Bring your own logger; the fallback is 25 dependency-free lines&lt;/li&gt;
&lt;li&gt;Fail-fast structured errors, observable lifecycle events, working optimistic locking via dedicated connections&lt;/li&gt;
&lt;li&gt;CI on Node 22/24 with a real Redis service container, npm trusted publishing (OIDC) with provenance, zero lifecycle scripts in the package&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's on npm as &lt;a href="https://www.npmjs.com/package/@pinceladasdaweb/redis" rel="noopener noreferrer"&gt;&lt;code&gt;@pinceladasdaweb/redis&lt;/code&gt;&lt;/a&gt;, source at &lt;a href="https://github.com/pinceladasdaweb/redis" rel="noopener noreferrer"&gt;github.com/pinceladasdaweb/redis&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lessons, second edition
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Docs are a spec you've already published.&lt;/strong&gt; Either the code honors them or the docs change. My README described a different, better library — and the gap was invisible because nothing tested the claims.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read everything before fixing anything.&lt;/strong&gt; The four worst bugs looked independent and were one defect: a reconnection layer that shouldn't exist. Patch-by-patch would have polished each symptom and kept the disease.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't own what your driver owns.&lt;/strong&gt; The best resilience code I wrote this month was a deletion. If you're wrapping a client that already reconnects, your job is what happens &lt;em&gt;around&lt;/em&gt; reconnection — state, events, dedicated connections — not a competing loop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A write that resolves while doing nothing is data loss with good manners.&lt;/strong&gt; Fail fast, fail loud, fail with a &lt;code&gt;code&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kill it from the server side.&lt;/strong&gt; &lt;code&gt;CLIENT KILL&lt;/code&gt; is one command. If your library claims resilience and your test suite has never dropped a connection for real, you don't have a resilience feature — you have a resilience rumor.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Same ending as last time, because it's still true: this library now survives everything I could think of throwing at it, and it has zero production mileage under other people's workloads. If you run Redis with Node and feel like breaking something, I'd genuinely love the bug reports.&lt;/p&gt;

&lt;p&gt;Turns out "looking finished" is still the bug. At least this time I knew where to look.&lt;/p&gt;

</description>
      <category>redis</category>
      <category>node</category>
      <category>javascript</category>
      <category>opensource</category>
    </item>
    <item>
      <title>My RabbitMQ library looked production-ready. It couldn't survive a single reconnect.</title>
      <dc:creator>Pedro Rogério</dc:creator>
      <pubDate>Sun, 19 Jul 2026 17:13:46 +0000</pubDate>
      <link>https://dev.to/pinceladasdaweb/my-rabbitmq-library-looked-production-ready-it-couldnt-survive-a-single-reconnect-1lp</link>
      <guid>https://dev.to/pinceladasdaweb/my-rabbitmq-library-looked-production-ready-it-couldnt-survive-a-single-reconnect-1lp</guid>
      <description>&lt;p&gt;For a couple of years I had a RabbitMQ client library sitting in a private repo. On paper it was impressive: automatic reconnection, channel pooling, a circuit breaker, four rate-limiting strategies, gzip compression, dead letter queues, worker-thread consumers, delayed messages. Over 1,200 lines in the main class alone.&lt;/p&gt;

&lt;p&gt;I never quite trusted it enough to publish. Recently I decided to find out whether that instinct was right.&lt;/p&gt;

&lt;p&gt;It was. &lt;strong&gt;The single most important feature of a message-broker client — surviving a connection drop — had never worked.&lt;/strong&gt; Not "worked poorly". Never worked. And nothing in the repo could have told me, because there wasn't a single test.&lt;/p&gt;

&lt;p&gt;This is the story of the audit, the rebuild, and the release of &lt;a href="https://www.npmjs.com/package/@pinceladasdaweb/rabbitmq" rel="noopener noreferrer"&gt;&lt;code&gt;@pinceladasdaweb/rabbitmq&lt;/code&gt;&lt;/a&gt;. It's also a collection of lessons I wish I'd internalized years earlier, because none of these bugs were exotic. They were all sitting in plain sight, waiting for a failure path to be exercised for the first time in production.&lt;/p&gt;




&lt;h2&gt;
  
  
  The audit: what "looks done" actually hides
&lt;/h2&gt;

&lt;p&gt;The library had the shape of a finished product. Rich API, detailed README, twenty example scripts. Here's a sample of what a deep read of the code actually found.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The reconnection that reconnected to nothing.&lt;/strong&gt; The connection layer dutifully detected drops, retried with exponential backoff, and re-established the TCP connection. Then... nothing. The channel pool — the thing every publish and consume goes through — was only ever created inside &lt;code&gt;connect()&lt;/code&gt;, and the reconnection path never called it. After any network blip, every operation would throw &lt;code&gt;Not connected&lt;/code&gt; forever, while the connection state proudly reported &lt;code&gt;connected&lt;/code&gt;. The recovery code &lt;em&gt;existed&lt;/em&gt;; it recreated consumers using channels captured in closures from before the drop — channels that were dead. It had clearly never been executed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The rate limiter that crashed on its own feature.&lt;/strong&gt; The block-a-key feature stored blocked keys in a &lt;code&gt;Set&lt;/code&gt;... and called &lt;code&gt;this.blocked.set(key, Date.now())&lt;/code&gt; on it. &lt;code&gt;Set&lt;/code&gt; has no &lt;code&gt;.set()&lt;/code&gt;. Calling &lt;code&gt;blockKey()&lt;/code&gt; threw a &lt;code&gt;TypeError&lt;/code&gt;, every time, since the day it was written.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The circuit breaker state that didn't exist.&lt;/strong&gt; The public API exposed &lt;code&gt;getCircuitBreakerState()&lt;/code&gt;, which called &lt;code&gt;this.#circuitBreaker.getState()&lt;/code&gt; — a method the circuit breaker class simply did not have.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The dead-letter helper with the wrong signature.&lt;/strong&gt; &lt;code&gt;moveToDeadLetter()&lt;/code&gt; called the publish method with four arguments in the wrong order, so the &lt;em&gt;name of the queue&lt;/em&gt; was published as the message body, to the wrong exchange.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The library that killed your process.&lt;/strong&gt; The constructor registered &lt;code&gt;SIGINT&lt;/code&gt;, &lt;code&gt;SIGTERM&lt;/code&gt;, &lt;code&gt;uncaughtException&lt;/code&gt; and &lt;code&gt;unhandledRejection&lt;/code&gt; handlers — and called &lt;code&gt;process.exit()&lt;/code&gt; in them. A &lt;em&gt;library&lt;/em&gt;. Any unhandled rejection anywhere in your application would cause my dependency to shut your process down.&lt;/p&gt;

&lt;p&gt;None of these are subtle bugs. They're the kind of thing the &lt;em&gt;first test you ever write&lt;/em&gt; catches. Which brings me to the first lesson.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Lesson 1: features are cheap; invariants are expensive.&lt;/strong&gt; An API surface can look complete while every failure path is fiction. The happy path is maybe 20% of an infrastructure library — the rest is what happens during the bad minutes, and that's exactly the part that "it runs on my machine" never exercises.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  If you claim resilience, your CI has to actually break things
&lt;/h2&gt;

&lt;p&gt;The rebuild started with the reliability core: reconnection has to restore the channel pool, re-assert topology, and recreate every consumer on fresh channels. But I'd been burned by "code that looks like it recovers", so the real work was making the claim &lt;em&gt;falsifiable&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The integration suite now runs against a real RabbitMQ (Docker locally, a service container in GitHub Actions) and includes this test — the one I care most about in the entire repo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;recovers channel pool and consumers after a forced connection drop&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reconnected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;rabbitMQ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;once&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;reconnected&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

  &lt;span class="c1"&gt;// Ask the broker to close every AMQP connection, simulating a real outage.&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;forceCloseAllConnections&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;// DELETE /api/connections/* via the management API&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;reconnected&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;countBefore&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;received&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;rabbitMQ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ROUTING_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;after&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;reconnect&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;waitFor&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;received&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;countBefore&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rabbitMQ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getClusterStatus&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;connectionState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;connected&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No mocks. The broker genuinely kills the connection; the library genuinely has to come back: pool rebuilt, exchange re-asserted, consumer re-subscribed on a new channel, message flowing again. This runs on every pull request.&lt;/p&gt;

&lt;p&gt;Using the management HTTP API for the kill switch (instead of &lt;code&gt;docker restart&lt;/code&gt;) was a small trick that paid off: it works identically on a laptop and inside a CI service container, and it's fast.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Lesson 2: a resilience feature without a test that injects the failure is a rumor.&lt;/strong&gt; The test that kills your broker connection is worth more than the thousand lines of recovery code it validates.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The adversarial review: 42 candidates, 10 confirmed bugs
&lt;/h2&gt;

&lt;p&gt;With the core rebuilt and a test harness in place, I ran a structured review of the source: multiple independent passes over the code, each hunting from a different angle — line-by-line, lifecycle invariants, cross-module contracts, plus efficiency and design-depth sweeps. Every candidate finding then got adversarially verified against the code before I accepted it.&lt;/p&gt;

&lt;p&gt;Ten confirmed bugs came out. Three of my favorites, because each one represents a &lt;em&gt;category&lt;/em&gt;:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The option that silently discarded messages.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;retryOperation &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;operation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;maxRetries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;maxRetries&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Spot it? Pass &lt;code&gt;{ maxRetries: 0 }&lt;/code&gt; — a perfectly reasonable "try once, don't retry" intent — and the loop body never runs. The operation is never attempted, &lt;code&gt;channel.publish&lt;/code&gt; is never called, and the promise &lt;strong&gt;resolves successfully&lt;/strong&gt;. The circuit breaker even records a success. Silent message loss, triggered by a documented option. The fix is one line (&lt;code&gt;Math.max(1, maxRetries)&lt;/code&gt;), plus a regression test that pins the semantics forever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The poison message that ate a consumer.&lt;/strong&gt; A message with a corrupt gzip body would fail to decode, get nacked with &lt;code&gt;requeue: true&lt;/code&gt;, be redelivered instantly, fail again... forever. With &lt;code&gt;prefetch: 1&lt;/code&gt;, one bad message parked an entire queue while burning 100% CPU. Undecodable messages now go to the dead letter queue — failure handling should &lt;em&gt;drain&lt;/em&gt; problems, not recirculate them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The unroutable dead-letter that vanished.&lt;/strong&gt; Moving a message to a DLQ published it without the &lt;code&gt;mandatory&lt;/code&gt; flag. If the dead-letter routing had no binding — one topology mistake away — the broker would &lt;em&gt;confirm&lt;/em&gt; the publish and silently drop the message. The method resolved, logged success, and the message ceased to exist. It now publishes with &lt;code&gt;mandatory: true&lt;/code&gt;, listens for &lt;code&gt;basic.return&lt;/code&gt;, and rejects loudly when the DLQ isn't routable.&lt;/p&gt;

&lt;p&gt;There were seven more: a &lt;code&gt;disconnect()&lt;/code&gt;/&lt;code&gt;connect()&lt;/code&gt; cycle that permanently disabled auto-reconnection, consumer callback failures tripping the circuit breaker that gates &lt;em&gt;publishing&lt;/em&gt;, a fire-and-forget publish that could hang forever awaiting &lt;code&gt;'drain'&lt;/code&gt; on a dead channel, duplicate consumers spawned by racing recovery paths...&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Lesson 3: bugs cluster in the seams.&lt;/strong&gt; Almost every confirmed finding lived at an intersection — between two recovery mechanisms, between an option's edge value and a default, between "the callback succeeded" and "the ack was delivered". Reviews that only read functions in isolation don't find these. You have to trace the &lt;em&gt;interactions&lt;/em&gt;.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Your test suite also protects you from everyone else
&lt;/h2&gt;

&lt;p&gt;Two things happened during this project that had nothing to do with my code, and the test suite caught both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The dependency major-bump.&lt;/strong&gt; Mid-rebuild, I upgraded &lt;code&gt;amqplib&lt;/code&gt; from 0.10 to 2.0 — a jump across two majors of the library this whole thing wraps. The entire integration suite (confirms, compression round-trips, forced reconnections, delayed messages) passed against the new version in one afternoon. Without the suite, that upgrade would have been an act of faith deferred indefinitely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The floating Docker tag.&lt;/strong&gt; My CI used &lt;code&gt;rabbitmq:4-management-alpine&lt;/code&gt;. One day that tag started resolving to RabbitMQ 4.3, which rejected the delayed-message plugin version I bundled (built for 4.0.x). CI went red before any user ever saw the problem. The fix taught me to &lt;strong&gt;pin brokers and plugins as a matched pair&lt;/strong&gt; (&lt;code&gt;rabbitmq:4.2-management-alpine&lt;/code&gt; + plugin &lt;code&gt;4.2.0&lt;/code&gt;) — a floating major tag in CI is a time bomb with a pleasant default.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Lesson 4: automated tests are not just about your bugs.&lt;/strong&gt; They're your early-warning system for upstream majors, ecosystem drift, and infrastructure that changes underneath you.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Dependencies are promises
&lt;/h2&gt;

&lt;p&gt;At the start, the library shipped four runtime dependencies, including &lt;code&gt;pino&lt;/code&gt; &lt;em&gt;and&lt;/em&gt; &lt;code&gt;pino-pretty&lt;/code&gt; — a pretty-printer for development logs, in every production install.&lt;/p&gt;

&lt;p&gt;It now ships &lt;strong&gt;two&lt;/strong&gt;: &lt;code&gt;amqplib&lt;/code&gt; and a small cache. The logger question is the interesting one. The library used to create its own pino instance at import time — its own format, its own destination, disconnected from whatever the host application does. But an infrastructure library shouldn't own a logging pipeline; it should write to whatever you give it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;pino&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pino&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rabbit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RabbitMQ&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;endpoints&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;localhost:5672&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;pino&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rabbitmq&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="c1"&gt;// any pino/winston/bunyan instance works&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you inject nothing, a 25-line dependency-free console logger provides timestamped, leveled output. Nobody pays for a logging stack they already have.&lt;/p&gt;

&lt;p&gt;The same thinking applied to process lifecycle: the library no longer touches your signal handlers. Graceful shutdown is one explicit call — &lt;code&gt;rabbit.enableGracefulShutdown()&lt;/code&gt; — and it's opt-in, because your process belongs to you, not to your dependencies.&lt;/p&gt;




&lt;h2&gt;
  
  
  A small release-engineering war story
&lt;/h2&gt;

&lt;p&gt;The publish pipeline (GitHub Actions → npm on every merge to &lt;code&gt;main&lt;/code&gt;) had its own baptism by fire. Two merges landed on &lt;code&gt;main&lt;/code&gt; in quick succession; two release runs raced each other. The first published &lt;code&gt;1.0.1&lt;/code&gt; to npm, then failed pushing the git tag back (the branch had moved). The second run recomputed "next version = 1.0.1" from the now-stale &lt;code&gt;package.json&lt;/code&gt; — and npm refused to publish over an existing version. Deadlock: every subsequent release run would fail the same way.&lt;/p&gt;

&lt;p&gt;Two structural fixes, both worth stealing:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The registry is the source of truth.&lt;/strong&gt; The workflow now asks npm what the latest published version is (&lt;code&gt;npm view &amp;lt;pkg&amp;gt; version&lt;/code&gt;) and bumps from &lt;em&gt;that&lt;/em&gt;, never from &lt;code&gt;package.json&lt;/code&gt;. Whatever half-finished state a previous run left behind, the next run self-corrects.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Serialize your releases.&lt;/strong&gt; A &lt;code&gt;concurrency: { group: publish }&lt;/code&gt; block means rapid merges queue instead of racing.&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Lesson 5: release automation fails in the middle, not at the edges.&lt;/strong&gt; Design every step to be safe to re-run, and derive state from systems of record, not from files a previous failed run may or may not have updated.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Where it landed
&lt;/h2&gt;

&lt;p&gt;The library is now public: &lt;a href="https://www.npmjs.com/package/@pinceladasdaweb/rabbitmq" rel="noopener noreferrer"&gt;&lt;code&gt;@pinceladasdaweb/rabbitmq&lt;/code&gt;&lt;/a&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @pinceladasdaweb/rabbitmq
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;RabbitMQ&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@pinceladasdaweb/rabbitmq&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rabbit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RabbitMQ&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;endpoints&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;localhost:5672&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;exchange&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;orders&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;direct&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;rabbit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;rabbit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;order-processing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;handleOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// auto-ack on success, nack → DLQ on throw&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;rabbit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;order.created&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="c1"&gt;// publisher-confirmed&lt;/span&gt;

&lt;span class="nx"&gt;rabbit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;reconnected&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;survived an outage, consumers restored&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What's in the box: automatic reconnection with full state recovery (pool, topology, consumers), publisher confirms on everything, per-key rate limiting (token bucket, leaky bucket, fixed and sliding windows), a circuit breaker that's isolated from consumer failures, transparent gzip compression, first-class dead-letter support (&lt;code&gt;createQueue&lt;/code&gt;, &lt;code&gt;moveToDeadLetter&lt;/code&gt;, &lt;code&gt;processDeadLetterQueue&lt;/code&gt;), delayed messages via the official plugin, worker-thread parallel consumers with automatic respawn, dependency-ordered sequential processing, consumer lifecycle management (&lt;code&gt;unsubscribe&lt;/code&gt;, &lt;code&gt;consumerCancelled&lt;/code&gt;/&lt;code&gt;consumerRecovered&lt;/code&gt;/&lt;code&gt;consumerLost&lt;/code&gt; events), and TypeScript declarations checked in strict mode on CI.&lt;/p&gt;

&lt;p&gt;Guarding all of it: &lt;strong&gt;87 automated tests&lt;/strong&gt; — 72 unit, 15 integration against a real broker including the forced-outage scenario — running on Node 22 and 24 on every pull request, plus 22 runnable examples that double as living documentation.&lt;/p&gt;

&lt;p&gt;And in the spirit of the whole post, the honest part: this library has excellent test coverage and zero production mileage under other people's workloads. Those are different kinds of confidence, and only one of them can be built alone. If you run RabbitMQ in Node and any of this resonates, I'd genuinely love an issue, a hard question, or a war story from your queue topology: &lt;a href="https://github.com/pinceladasdaweb/rabbitmq" rel="noopener noreferrer"&gt;github.com/pinceladasdaweb/rabbitmq&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The library I was too embarrassed to publish taught me more in this rebuild than it did in all the years it sat in a folder looking finished. Turns out "looking finished" was the bug.&lt;/p&gt;

</description>
      <category>node</category>
      <category>rabbitmq</category>
      <category>testing</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Node URL Shortener</title>
      <dc:creator>Pedro Rogério</dc:creator>
      <pubDate>Fri, 11 Jun 2021 19:49:08 +0000</pubDate>
      <link>https://dev.to/pinceladasdaweb/node-url-shortener-4234</link>
      <guid>https://dev.to/pinceladasdaweb/node-url-shortener-4234</guid>
      <description>&lt;p&gt;I recently ended up creating a URL shortener using NodeJS, Fastify, Postgres and Redis for study purposes. If you think it might be useful for you, if only for study purposes too, come take a look: &lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/pinceladasdaweb/node-url-shortener"&gt;https://github.com/pinceladasdaweb/node-url-shortener&lt;/a&gt;&lt;/p&gt;

</description>
      <category>node</category>
      <category>postgres</category>
      <category>redis</category>
      <category>fastify</category>
    </item>
    <item>
      <title>Developing a RESTful API using Fastify</title>
      <dc:creator>Pedro Rogério</dc:creator>
      <pubDate>Mon, 08 Feb 2021 10:58:57 +0000</pubDate>
      <link>https://dev.to/pinceladasdaweb/developing-a-restful-api-using-fastify-4763</link>
      <guid>https://dev.to/pinceladasdaweb/developing-a-restful-api-using-fastify-4763</guid>
      <description>&lt;p&gt;You who are tired of seeing examples of REST API in Node using Express will be very interested in this boilerplate that I ended up developing using Traefik, Docker, Docker Compose, Fastify, JWT and Mongodb.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/pinceladasdaweb/docker-fastify-restful-api"&gt;https://github.com/pinceladasdaweb/docker-fastify-restful-api&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Happy coding!&lt;/p&gt;

</description>
      <category>node</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
