<?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: Dhananjay Mundhada</title>
    <description>The latest articles on DEV Community by Dhananjay Mundhada (@dhananjay_mundhada_02c326).</description>
    <link>https://dev.to/dhananjay_mundhada_02c326</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%2F4205263%2F43941b82-a454-4334-a4f0-c9b3fc1c3bfe.png</url>
      <title>DEV Community: Dhananjay Mundhada</title>
      <link>https://dev.to/dhananjay_mundhada_02c326</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dhananjay_mundhada_02c326"/>
    <language>en</language>
    <item>
      <title>The retry storm that ate 40,000 messages</title>
      <dc:creator>Dhananjay Mundhada</dc:creator>
      <pubDate>Sat, 24 Oct 2026 19:40:47 +0000</pubDate>
      <link>https://dev.to/dhananjay_mundhada_02c326/the-retry-storm-that-ate-40000-messages-70g</link>
      <guid>https://dev.to/dhananjay_mundhada_02c326/the-retry-storm-that-ate-40000-messages-70g</guid>
      <description>&lt;h2&gt;
  
  
  2 a.m.
&lt;/h2&gt;

&lt;p&gt;An API token expired overnight. That was the whole root cause: one credential, for one&lt;br&gt;
third-party API, reached the end of its life while nobody was looking.&lt;/p&gt;

&lt;p&gt;The consumer that depended on it was not careless code. It was built the way the tutorials&lt;br&gt;
and the postmortems say to build one. It caught exceptions and logged them with a stack trace.&lt;br&gt;
It retried, with a limit, so no message could go around forever. Past the limit, the broker&lt;br&gt;
dead-lettered the message instead of dropping it. By every checklist I knew, it was a&lt;br&gt;
well-built consumer.&lt;/p&gt;

&lt;p&gt;It did exactly what it was built to do. Every message called the API, was refused, logged an&lt;br&gt;
error, went back on the queue, and tried again. Each message found out about the expired token&lt;br&gt;
on its own. Each one used up its retries on its own. Then each one went to the dead-letter&lt;br&gt;
queue, on its own.&lt;/p&gt;

&lt;p&gt;By morning the dead-letter queue held 40,000 messages, and every one of them was customer data&lt;br&gt;
that should have reached the vendor and hadn't. Technically nothing was lost. But nothing had&lt;br&gt;
been done either, and the recovery was manual: fix the token, move the messages back by hand,&lt;br&gt;
and hope nothing else had landed in that queue for a different reason.&lt;/p&gt;

&lt;p&gt;What stayed with me afterwards wasn't the token. Tokens expire. It was that the system had the&lt;br&gt;
information it needed to stop after the first failure, and no part of it was in a position to&lt;br&gt;
use it. Every message failed for the same reason, and every message was treated as if its&lt;br&gt;
failure were its own.&lt;/p&gt;

&lt;p&gt;The usual fix is a hand-rolled circuit breaker for that one consumer. Then the next consumer&lt;br&gt;
needs one, and each is slightly different from the last. This article&lt;br&gt;
is about the version I'd want instead, and the reference implementation I built for it:&lt;br&gt;
&lt;a href="https://github.com/dhananjaym-dev/traction" rel="noopener noreferrer"&gt;Traction&lt;/a&gt;, open source, running on a laptop with&lt;br&gt;
Docker. Every measured number below comes from its included demo, not from that night.&lt;/p&gt;
&lt;h2&gt;
  
  
  The failure has three sizes
&lt;/h2&gt;

&lt;p&gt;A queue consumer calling a third party sees one thing when a call fails: an exception. But&lt;br&gt;
that exception can mean three very different things, and the right response to each is&lt;br&gt;
different.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One bad record.&lt;/strong&gt; A contact with an invalid email address. Retrying it will never work,
and it says nothing about the next message. The right move is to reject it and keep going.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One broken endpoint.&lt;/strong&gt; The vendor changed the contract for company records. Every company
sync fails, while contact syncs against the same vendor are fine. The right move is to stop
company syncs, keep the work, and let contacts carry on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The whole connection.&lt;/strong&gt; The token expired. Every call of every kind fails, and will keep
failing until a human does something. The right move is to stop everything on the first
failure and keep every message until that human is done.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxrzubukb2c2zkn42f515.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxrzubukb2c2zkn42f515.png" alt="Three failure sizes, each shown as flights inside the acme CRM airport. One flight (One contact has an invalid email): Inside acme CRM, one flight in contact-sync has failed. Every other contact-sync flight and every company-sync flight is fine. One gate (The company endpoint changed its contract): Inside acme CRM, every company-sync flight has failed. Every contact-sync flight is fine. The whole airport (The token expired): Everything inside acme CRM has failed: every contact-sync flight and every company-sync flight." width="800" height="1575"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;One dependency, three sizes of failure, three different right moves. A retry policy applies the same move to all three.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A retry policy can't tell these apart. It sees one exception at a time, in one consumer&lt;br&gt;
thread, on one pod. A retry count, a backoff schedule and a dead-letter queue are all&lt;br&gt;
per-message tools, so they apply the per-message move to all three sizes: try again, then give&lt;br&gt;
up. For the bad record that's harmless waste. For the broken endpoint it's an alert per&lt;br&gt;
message. For the dead token it's the storm.&lt;/p&gt;

&lt;p&gt;Some failures aren't a size at all. A timeout, a 5xx blip or the vendor throttling you says&lt;br&gt;
"not right now", not "this is broken". Those deserve a pause or a backoff, and retries are the&lt;br&gt;
right tool for them, which is part of why retries get used for everything else.&lt;/p&gt;

&lt;p&gt;The important thing about the three sizes is where they come from. Whether a failure is&lt;br&gt;
record-sized, endpoint-sized or connection-sized is a property of &lt;em&gt;how your work depends on&lt;br&gt;
the vendor&lt;/em&gt;, not of the exception's class name. An expired token is connection-sized for&lt;br&gt;
every consumer that shares it. A changed endpoint is sized to the work that calls it. That&lt;br&gt;
means the size can be declared once, up front, instead of rediscovered by every message.&lt;/p&gt;

&lt;p&gt;Acting on it needs shared state. One consumer thread can't see that three &lt;em&gt;different&lt;/em&gt;&lt;br&gt;
company records failed the same way in five minutes, across every pod: that takes a shared&lt;br&gt;
counter. Stopping every pod at once takes a shared status, and pacing every pod to one vendor&lt;br&gt;
quota takes a shared rate limit. Then someone has to notify a human once, not once per&lt;br&gt;
message, and give them a way to resume. That's a lot of plumbing to write per consumer. The&lt;br&gt;
&lt;a href="https://dhananjaym-dev.github.io/traction/the-problem/" rel="noopener noreferrer"&gt;problem page&lt;/a&gt; walks the three sizes through two complete examples.&lt;/p&gt;
&lt;h2&gt;
  
  
  Think airports, not retries
&lt;/h2&gt;

&lt;p&gt;I needed words for the three scopes that weren't "service", "queue" and "message", which all&lt;br&gt;
mean something else already. I ended up with an airport.&lt;/p&gt;

&lt;p&gt;An &lt;strong&gt;airport&lt;/strong&gt; is one governed dependency: one shared quota, one credential set, one outage&lt;br&gt;
fate. A &lt;strong&gt;gate&lt;/strong&gt; is one independently stoppable work type on that dependency. A &lt;strong&gt;flight&lt;/strong&gt; is&lt;br&gt;
one message. In the demo, a CRM account is the airport, contact sync and company sync are its&lt;br&gt;
gates, and each queued sync is a flight:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;airport  crm:acme        one quota, one credential, one outage fate
├── gate  contact-sync   one work type, stoppable on its own
│   └── flights          one message each
└── gate  company-sync
    └── flights
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The containment rule follows from the nesting: a stop at one level never touches its&lt;br&gt;
siblings. Stopping &lt;code&gt;company-sync&lt;/code&gt; leaves &lt;code&gt;contact-sync&lt;/code&gt; landing. Stopping the airport stops&lt;br&gt;
both. Rejecting one flight stops nothing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpywbaarcstzw1gavalqc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpywbaarcstzw1gavalqc.png" alt="The airport crm:acme, one CRM account, contains two gates, contact-sync and company-sync. Each gate holds its own flights, one message each, shown as landed, parked or skipped: contacts 1042 to 1046 in contact-sync, companies 88 to 92 in company-sync." width="800" height="1605"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The nesting is the containment rule: a stop at one level never touches its siblings.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The other word that matters is &lt;strong&gt;ground stop&lt;/strong&gt;. When an airport is ground-stopped, its&lt;br&gt;
consumers stop taking work, and the work stays on the &lt;strong&gt;runway&lt;/strong&gt;, the airport's queue. Nothing&lt;br&gt;
retries, nothing dead-letters, nothing calls the vendor. When a human&lt;br&gt;
&lt;strong&gt;reopens&lt;/strong&gt; the airport, the flights take off again, paced by the shared rate limit.&lt;/p&gt;

&lt;p&gt;That is the whole difference: a retry policy's unit is the message, and it gives up on the&lt;br&gt;
message. A ground stop's unit is the scope, and it stops the line but keeps the work.&lt;/p&gt;

&lt;p&gt;The aviation words are a memory hook, not an entry fee. Every term has a plain-words alias in&lt;br&gt;
the &lt;a href="https://dhananjaym-dev.github.io/traction/concepts/#glossary" rel="noopener noreferrer"&gt;glossary&lt;/a&gt;, and the code uses the same words as the docs, so a&lt;br&gt;
&lt;code&gt;GATE_FAULT&lt;/code&gt; in a log line means exactly what it means here.&lt;/p&gt;
&lt;h2&gt;
  
  
  What you write (all of it)
&lt;/h2&gt;

&lt;p&gt;Here is the demo's CRM integration, the real code from&lt;br&gt;
&lt;a href="https://github.com/dhananjaym-dev/traction/blob/main/demo-app/src/main/java/dev/traction/demo/governed/CrmAirportConfig.java" rel="noopener noreferrer"&gt;&lt;code&gt;CrmAirportConfig.java&lt;/code&gt;&lt;/a&gt;,&lt;br&gt;
trimmed for width. It has four pieces.&lt;/p&gt;

&lt;p&gt;First, declare what you're protecting. This one bean is the airport: its id, its rate limit,&lt;br&gt;
its gates, and the two pieces of your own code it runs.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Bean&lt;/span&gt;
&lt;span class="nc"&gt;AirportSpec&lt;/span&gt; &lt;span class="nf"&gt;crmAcme&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;AirportSpec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"crm:acme"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;slots&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofSeconds&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;// the shared rate limit&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;gate&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"contact-sync"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;               &lt;span class="c1"&gt;// one stoppable work type&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;gate&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"company-sync"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;pilot&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;land&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;                  &lt;span class="c1"&gt;// your work&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;reporter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;            &lt;span class="c1"&gt;// your failure classification&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Second, send work from anywhere in your system. The first argument to &lt;code&gt;DispatchOptions&lt;/code&gt; is&lt;br&gt;
the business id the work stays idempotent on, because delivery is at least once. The second&lt;br&gt;
is for bulk runs and is &lt;code&gt;null&lt;/code&gt; here:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;tower&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;dispatch&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"crm:acme"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"contact-sync"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;contact&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;DispatchOptions&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;contact&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Third, the &lt;strong&gt;pilot&lt;/strong&gt;: your code that does the work for one flight. It makes the call and&lt;br&gt;
nothing else. It never touches the broker, never acks, never decides whether to retry.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;PATHS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;   &lt;span class="c1"&gt;// gate -&amp;gt; Acme endpoint&lt;/span&gt;
        &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"contact-sync"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"/contacts"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"company-sync"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"/companies"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;land&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Flight&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;?&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;flight&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;acme&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;upsert&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;PATHS&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;flight&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;gateId&lt;/span&gt;&lt;span class="o"&gt;()),&lt;/span&gt; &lt;span class="n"&gt;flight&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Fourth, the &lt;strong&gt;reporter&lt;/strong&gt;: your code that says what an outcome means. This is where the three&lt;br&gt;
sizes live. Each failure is classified to the level it breaks, and that classification is the&lt;br&gt;
only containment logic you write.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;FlightReport&lt;/span&gt; &lt;span class="nf"&gt;report&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Flight&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;?&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;flight&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Throwable&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;landed&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nc"&gt;AuthException&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;              &lt;span class="c1"&gt;// dead token: the airport stops&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;incident&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;AIRPORT_FAULT&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Evidence&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nc"&gt;ContractException&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;// a streak stops this gate&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;incident&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;GATE_FAULT&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Evidence&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nc"&gt;BadPayloadException&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;        &lt;span class="c1"&gt;// bad record: rejected&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;incident&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;FLIGHT_FAULT&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Evidence&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nc"&gt;VendorUnavailableException&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// blip: hold, then park&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;incident&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;TRANSIENT&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Evidence&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nc"&gt;RateLimitedException&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;       &lt;span class="c1"&gt;// throttled: pace down&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;incident&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;RATE_LIMIT&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Evidence&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;incident&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;INTERNAL&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Evidence&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// our bug: hold, then park&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The exception types are the demo's own; yours would be whatever your client throws, or an&lt;br&gt;
HTTP status code. &lt;code&gt;Evidence.from(t)&lt;/code&gt; keeps a bounded excerpt of what went wrong, so the&lt;br&gt;
incident list and the notification can say &lt;em&gt;why&lt;/em&gt; without a logging stack behind them.&lt;/p&gt;

&lt;p&gt;That's the whole integration. Adding a second vendor means writing another spec, pilot and&lt;br&gt;
reporter: the code that is actually about that vendor. Everything else comes from the&lt;br&gt;
declaration. The queues are declared on your broker and the consumers started. The rate limit&lt;br&gt;
is enforced across every pod. The ground-stop and streak rules apply, and so do backoff,&lt;br&gt;
parking, replay and discard. Bulk runs are tracked to completion. Notifications fire, the&lt;br&gt;
dashboard shows the fleet, and an admin API covers every operator action.&lt;/p&gt;
&lt;h2&gt;
  
  
  What the framework does with it
&lt;/h2&gt;

&lt;p&gt;Traction splits the work across two planes.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;data plane&lt;/strong&gt; runs in every service that does work. For each flight it pulls from the&lt;br&gt;
runway, it checks the cheap things first: is the airport stopped, is the vendor throttling us,&lt;br&gt;
is this gate stopped, was this flight's bulk run aborted? Only then does it take a slot from&lt;br&gt;
the shared rate limit, call your pilot, and ask your reporter what happened. A stopped scope&lt;br&gt;
never burns a vendor call.&lt;/p&gt;

&lt;p&gt;Then it settles the message, and there are only four ways out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Land.&lt;/strong&gt; The message is acked and done: it landed, or it was a bad record and is rejected.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hold.&lt;/strong&gt; A blip, or your own bug: the message goes to a holding queue with backoff
(30 seconds, 2 minutes, 10 minutes for a blip; one 30-second hold for a bug) and comes back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Park.&lt;/strong&gt; Past the hold budget, on a &lt;code&gt;GATE_FAULT&lt;/code&gt;, or on a stopped gate: the message waits
for a human. Parked is recoverable, never counted as failed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Defer.&lt;/strong&gt; The airport is down or throttled: the message goes back to the tail of the
runway, untouched. If the airport is down, the consumer pauses until a reopen; if it's
throttled, the airport waits out one rate-limit window.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvkbhh5lol8dq7tyqpcwr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvkbhh5lol8dq7tyqpcwr.png" alt="Two planes. Data plane, in every service that does work: dispatch puts a flight on the airport's runway queue, tr.&lt;airportId&gt;.runway. A status check asks whether the airport or gate is stopped, whether the vendor is throttling, and whether the flight's charter was aborted. Clearance takes a slot from the shared Redis bucket. Then your pilot does the work and your reporter classifies the outcome; these two are your code, shown in yellow. Settle acks landed and rejected flights, sends held flights up to the holding queue whose TTL returns them to the runway, parks the rest for a human, and defers to the runway tail when the airport is down. Each step sends a report to tr.tower.queue. Control plane, in one service: the tower counts streaks into ground stops, flips statuses in Redis, keeps charter counters and calls the NotifyHook on changes; stops and reopens reach every consumer's status check over tr:events." width="800" height="378"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Yellow is your code. Everything else, in both planes, is the framework. The planes never call each other: reports travel on the broker, statuses live in the store.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(click the image to view it full size, or see the &lt;a href="https://dhananjaym-dev.github.io/traction/#what-the-framework-does-with-it" rel="noopener noreferrer"&gt;live version&lt;/a&gt;).&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every settled step is sent as a report to the &lt;strong&gt;control plane&lt;/strong&gt;, which runs in one service.&lt;br&gt;
It reads every report, keeps the streaks, and flips the statuses the data plane obeys. The&lt;br&gt;
rules are short. The &lt;em&gt;first&lt;/em&gt; &lt;code&gt;AIRPORT_FAULT&lt;/code&gt; ground-stops the airport: a dead credential&lt;br&gt;
doesn't need a quorum. Three &lt;code&gt;GATE_FAULT&lt;/code&gt;s for three distinct records in five minutes stop&lt;br&gt;
that gate. Five &lt;code&gt;FLIGHT_FAULT&lt;/code&gt;s or &lt;code&gt;INTERNAL&lt;/code&gt;s for distinct records in five minutes stop the&lt;br&gt;
gate too, because when every record is "bad", the records probably aren't the problem. A&lt;br&gt;
landing clears its gate's streaks. A reopen is always a human's call; nothing reopens itself.&lt;/p&gt;

&lt;p&gt;Status changes are compare-and-set, so any number of racing reports of the same expired token&lt;br&gt;
produce one ground stop and one notification. Then every consumer of that airport, on every&lt;br&gt;
pod, is told to pause.&lt;/p&gt;

&lt;p&gt;The two planes never call each other. Reports travel as JSON messages on your message broker.&lt;br&gt;
Statuses live in a Redis-compatible store, and changes are announced over the store's&lt;br&gt;
pub/sub, with the stored status as the source of truth for any process that missed the&lt;br&gt;
announcement. There is no HTTP between the planes and no shared database. In the demo both&lt;br&gt;
run inside one Spring Boot app, because that's the simplest thing to start, but nothing&lt;br&gt;
requires it: the control plane can be its own service, and the governed services can be&lt;br&gt;
deployed and scaled independently of it and of each other.&lt;/p&gt;

&lt;p&gt;Because the contract between the planes is JSON on the broker plus documented key shapes in&lt;br&gt;
the store, a data plane in another runtime is possible in principle. Java 21 and Spring Boot&lt;br&gt;
are what this repo ships, and nothing else is demonstrated in it. As a first check on that&lt;br&gt;
claim, a separate &lt;a href="https://github.com/dhananjaym-dev/traction-python" rel="noopener noreferrer"&gt;Python data plane&lt;/a&gt;&lt;br&gt;
interoperates with this repo's unmodified control plane.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://dhananjaym-dev.github.io/traction/life-of-a-flight/" rel="noopener noreferrer"&gt;life of a flight&lt;/a&gt; has the step-by-step version, with a diagram of&lt;br&gt;
both planes and the full settle table.&lt;/p&gt;
&lt;h2&gt;
  
  
  The same outage, twice
&lt;/h2&gt;

&lt;p&gt;The repo includes a demo that recreates the night from the start of this article, at a&lt;br&gt;
smaller scale, with a simulated vendor. Both scenarios run in Docker against the same broker,&lt;br&gt;
store and fake CRM. In both, the CRM's token has just expired and&lt;br&gt;
1,000 contact syncs arrive.&lt;/p&gt;

&lt;p&gt;The first scenario is the baseline: a plain &lt;code&gt;@RabbitListener&lt;/code&gt; with a retry limit of 3 and a&lt;br&gt;
dead-letter queue. That's the well-built consumer from the start of this article, written the&lt;br&gt;
way most teams write it after their first incident. A retry limit of 3 means 3 redeliveries,&lt;br&gt;
so 4 deliveries in all. Every message calls the dead API 4 times, logs 4 errors with stack&lt;br&gt;
traces, and ends up in the dead-letter queue. Nothing notices that the failures share a cause.&lt;/p&gt;

&lt;p&gt;The second scenario sends the same 1,000 messages through Traction. The first flight to&lt;br&gt;
report &lt;code&gt;AIRPORT_FAULT&lt;/code&gt; ground-stops the airport. Flights already in progress fail the same way&lt;br&gt;
and go back to the runway tail. After that, nothing calls the vendor. Exactly one notification&lt;br&gt;
goes out: a single &lt;code&gt;traction.notify type=GROUND_STOP&lt;/code&gt; log line from the default hook, naming&lt;br&gt;
the airport, the reason (&lt;code&gt;AIRPORT_FAULT&lt;/code&gt;) and an evidence excerpt (&lt;code&gt;AuthException | 401&lt;/code&gt;&lt;br&gt;
with the vendor's &lt;code&gt;token expired&lt;/code&gt; body).&lt;/p&gt;

&lt;p&gt;All 1,000 flights wait on the runway. The script then fixes the token, sends one reopen&lt;br&gt;
&lt;code&gt;curl&lt;/code&gt;, and watches every flight land, paced at the airport's 20-per-second limit. It checks&lt;br&gt;
that nothing is parked and nothing is dead-lettered.&lt;/p&gt;

&lt;p&gt;A measured run of the included demo, on a laptop, ended each scenario with one result line.&lt;br&gt;
Here they are, wrapped for width, with the scenario labels added:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;naive:
lost: 1000 · error lines: ~4000
recovery: manual

traction:
lost: 0 · notifications: 1
recovery: one curl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0w4dquulbnn1e7291u77.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0w4dquulbnn1e7291u77.png" alt="The Traction dashboard mid-incident: the company-sync gate is ground-stopped with 50 flights parked, and the contact-sync gate stays open with 113 flights waiting on the runway." width="800" height="415"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A frame frozen mid-run of &lt;code&gt;scenario-gate.sh&lt;/code&gt;, the gate scenario, not the outage above: one gate is stopped on a contract change while its sibling keeps flying.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;"Lost" on the naive side means dead-lettered: the messages still exist, but every one of them&lt;br&gt;
is now someone's manual job. Your numbers will vary with timing; the shape won't, and the&lt;br&gt;
scripts assert it, so a regression fails the run.&lt;/p&gt;

&lt;p&gt;The naive consumer has no bug: it did what its configuration said, about 4,000 times. It just&lt;br&gt;
had no place to put the knowledge that one failure meant all of them would fail. Traction has&lt;br&gt;
exactly one place for it, the reporter's &lt;code&gt;AIRPORT_FAULT&lt;/code&gt;, and the first report that uses it&lt;br&gt;
ends the storm.&lt;/p&gt;
&lt;h2&gt;
  
  
  Day two: the operator's view
&lt;/h2&gt;

&lt;p&gt;An outage has a second half: the morning after, when someone has to work out what state&lt;br&gt;
things are in and put them right. Here, every operator action is one &lt;code&gt;curl&lt;/code&gt; against the admin&lt;br&gt;
API on the control-plane service, and every action is accounted for.&lt;/p&gt;

&lt;p&gt;The token is fixed. Let the airport fly:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;API&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://localhost:8080/traction/api
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="nv"&gt;$API&lt;/span&gt;/airports/crm:acme/reopen
&lt;span class="c"&gt;# {"airportId":"crm:acme","reopened":true}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;A gate stopped on a streak works the same way, one level down. If the company endpoint had changed&lt;br&gt;
its contract, &lt;code&gt;company-sync&lt;/code&gt; would have stopped after three distinct failures, and its flights&lt;br&gt;
would have parked while contacts kept landing. Once the integration is fixed, reopen the gate and replay what&lt;br&gt;
parked:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="nv"&gt;$API&lt;/span&gt;/airports/crm:acme/gates/company-sync/reopen
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="nv"&gt;$API&lt;/span&gt;/airports/crm:acme/replayParked
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The order matters, and the docs say so: fix the cause, then reopen, then replay. Replaying&lt;br&gt;
into a stopped gate just parks the flights again, harmlessly.&lt;/p&gt;

&lt;p&gt;Bulk runs, which Traction calls &lt;strong&gt;charters&lt;/strong&gt;, get their own controls. A charter is a run&lt;br&gt;
dispatched against an expected count, so the framework can tell you how far along it is and&lt;br&gt;
when it's done. If a run turns out to be wrong halfway through, abort it:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'X-Actor: dana'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;$API&lt;/span&gt;/charters/mail:acme/welcome/run-1792774953/abort
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The run's remaining flights are counted as skipped as they reach a consumer, and dropped&lt;br&gt;
without flying. They are accounted for but not kept, so redoing that work means dispatching it&lt;br&gt;
again from your source. When a charter completes, its books balance:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;landed + rejected + skipped + parked + discarded == expected
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Two things matter more to me than the curls themselves. First, nothing here silently loses&lt;br&gt;
work. Reopen and replay lose nothing; abort skips work on purpose, and counts it. The one&lt;br&gt;
action that drops work Traction still holds, discarding an airport's parked flights, requires&lt;br&gt;
an explicit confirm token, and anything else is a 400 that drops nothing. Second, the operator&lt;br&gt;
doesn't need a log search to know what happened. A stopped airport or gate carries a reason&lt;br&gt;
and an evidence excerpt, and the dashboard polls the same API. It has no buttons on purpose:&lt;br&gt;
the runbook is curl, so it can be pasted into a ticket.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://dhananjaym-dev.github.io/traction/operations/" rel="noopener noreferrer"&gt;operations page&lt;/a&gt; is the full runbook, with every response body.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why not a workflow engine / outbox / more retries
&lt;/h2&gt;

&lt;p&gt;Three fair objections.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A workflow engine.&lt;/strong&gt; Temporal and the other durable-execution runtimes retry failed steps,&lt;br&gt;
survive crashes, and can rate-limit work. If the work is a &lt;em&gt;process&lt;/em&gt;, use one: several steps,&lt;br&gt;
compensation on failure, timers that run for days, a human approval in the middle. Traction&lt;br&gt;
has none of that, deliberately; its unit is one call to one dependency. The difference that&lt;br&gt;
matters here is shared fate. A durable runtime makes each step robust on its own. When the token expires, a thousand activities each&lt;br&gt;
discover it, each retry on their own policy, and each fail or wait in turn. You can build a&lt;br&gt;
circuit breaker around that, and then you're writing the containment layer this repo already&lt;br&gt;
is. They also compose: a workflow step can dispatch flights and let Traction contain the&lt;br&gt;
vendor. There's a longer comparison in &lt;a href="https://dhananjaym-dev.github.io/traction/why-not-temporal/" rel="noopener noreferrer"&gt;why not Temporal?&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The transactional outbox.&lt;/strong&gt; The outbox solves a different problem: publishing a message&lt;br&gt;
reliably, in the same transaction as the database write that caused it. It gets the message&lt;br&gt;
onto the queue, and says nothing about what the consumer does when the vendor behind it is&lt;br&gt;
down. If you use one, keep it; Traction starts where it ends.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;More retries, or smarter ones.&lt;/strong&gt; Longer backoff, jitter, exponential schedules: all of them&lt;br&gt;
make each message wait longer before it discovers the same expired token. With 4 deliveries&lt;br&gt;
per message, the storm is 4 times the backlog. With a smarter schedule, it's the same storm,&lt;br&gt;
spread over more hours. A per-process circuit breaker is the closest thing, and it is the&lt;br&gt;
hand-rolled one mentioned at the start: it sees one pod's&lt;br&gt;
failures, it doesn't park work, and it gets written again for the next consumer. Retries stay&lt;br&gt;
in Traction, for the failures that really are "not right now".&lt;/p&gt;

&lt;p&gt;What it costs, since build-versus-buy is the real question:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Infrastructure:&lt;/strong&gt; your message broker and a Redis-compatible store. The demo runs RabbitMQ
with quorum queues and Valkey. If you already run both, there's no new box in your
architecture diagram; if you don't, that's the bill.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One control-plane service.&lt;/strong&gt; One of your services sets a property and becomes the control
plane. It hosts the rules, the admin API and the dashboard.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idempotent pilots.&lt;/strong&gt; Delivery is at least once. A flight can arrive twice, so the pilot
keys its writes on the business id you pass at dispatch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your own auth.&lt;/strong&gt; The admin API and the dashboard ship with no authentication in v1. Put
them behind your own security, or off the public ingress, or both.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A reference implementation, not a library.&lt;/strong&gt; This is a runnable codebase to read,
run and adapt. It's Java 21 and Spring Boot. The concepts come from production; the code is
written to be lifted from.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;a href="https://dhananjaym-dev.github.io/traction/guarantees/" rel="noopener noreferrer"&gt;guarantees page&lt;/a&gt; states exactly what is promised, and lists the known&lt;br&gt;
limits of v1, each one a deliberate trade.&lt;/p&gt;
&lt;h2&gt;
  
  
  Run it
&lt;/h2&gt;

&lt;p&gt;You need Docker. The images build with Gradle inside Docker, so you don't need a JDK; the&lt;br&gt;
scenario scripts use bash, curl and jq.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/dhananjaym-dev/traction.git &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;traction
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--build&lt;/span&gt; &lt;span class="nt"&gt;--wait&lt;/span&gt;
./demo/scenario-traction.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/dhananjaym-dev" rel="noopener noreferrer"&gt;
        dhananjaym-dev
      &lt;/a&gt; / &lt;a href="https://github.com/dhananjaym-dev/traction" rel="noopener noreferrer"&gt;
        traction
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Traction&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Automatic failure containment for queue consumers.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read the article &amp;amp; docs:&lt;/strong&gt; &lt;a href="https://dhananjaym-dev.github.io/traction/" rel="nofollow noopener noreferrer"&gt;https://dhananjaym-dev.github.io/traction/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When your async work starts failing, Traction stops the right scope (one work type, or a whole
dependency) instead of burning retries and flooding your alerts. It runs on the RabbitMQ and
Redis you already have, and the control plane lives inside your own service. No new
infrastructure.&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Why this exists&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;At 2 a.m. the CRM's API token expires. A thousand queued contact syncs churn through their
retries, every one of them logs an error, and the next morning someone shovels a dead-letter
queue back by hand, hoping nothing else was in there. The fix next sprint is a hand-rolled
circuit breaker for &lt;em&gt;that one consumer&lt;/em&gt;, and the same code gets written again for the next one.&lt;/p&gt;
&lt;p&gt;Every queue-based system ends up needing the same controls: back off when the dependency is
rate-limited, stop consuming when…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/dhananjaym-dev/traction" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;That's the governed scenario. &lt;code&gt;./demo/scenario-naive.sh&lt;/code&gt; runs the baseline,&lt;br&gt;
&lt;code&gt;./demo/scenario-gate.sh&lt;/code&gt; breaks one gate while the rest of the airport keeps flying, and&lt;br&gt;
&lt;code&gt;./demo/scenario-charter.sh&lt;/code&gt; aborts a bulk run part way through. Each resets the demo first. While they run, the dashboard is at&lt;br&gt;
&lt;a href="http://localhost:8080/traction" rel="noopener noreferrer"&gt;http://localhost:8080/traction&lt;/a&gt;. &lt;code&gt;docker compose down -v&lt;/code&gt; removes everything.&lt;/p&gt;

&lt;p&gt;If you want to go further than the demo, the docs read in this order:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://dhananjaym-dev.github.io/traction/the-problem/" rel="noopener noreferrer"&gt;The problem&lt;/a&gt;: the three sizes, worked through twice.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dhananjaym-dev.github.io/traction/concepts/" rel="noopener noreferrer"&gt;Concepts &amp;amp; glossary&lt;/a&gt;: every term and its plain-words alias.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dhananjaym-dev.github.io/traction/life-of-a-flight/" rel="noopener noreferrer"&gt;The life of a flight&lt;/a&gt;: both planes, step by step.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dhananjaym-dev.github.io/traction/guarantees/" rel="noopener noreferrer"&gt;Guarantees&lt;/a&gt;: what is promised, and the known limits.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dhananjaym-dev.github.io/traction/operations/" rel="noopener noreferrer"&gt;Operations&lt;/a&gt;: the admin API, notifications and configuration.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dhananjaym-dev.github.io/traction/why-not-temporal/" rel="noopener noreferrer"&gt;Why not Temporal?&lt;/a&gt;: when a durable runtime is the better choice.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The code is at &lt;a href="https://github.com/dhananjaym-dev/traction" rel="noopener noreferrer"&gt;github.com/dhananjaym-dev/traction&lt;/a&gt;,&lt;br&gt;
under the Apache 2.0 license. The reporter and the &lt;code&gt;AirportSpec&lt;/code&gt; are the parts most worth&lt;br&gt;
lifting into your own consumers, whether or not you use the rest.&lt;/p&gt;

</description>
      <category>java</category>
      <category>architecture</category>
      <category>devops</category>
      <category>systemdesign</category>
    </item>
  </channel>
</rss>
