The problem: failures come in three sizes
A retry policy treats every failure as the same size. They aren't, and the size decides the right move.
A queue consumer calling a third party sees one thing when it fails: an exception. But that exception can mean "this record is bad", "this kind of request is broken", or "nothing will work until a human fixes the credentials". Retrying all three the same way is how 1,000 messages turn into 4,000 failed calls and a dead-letter queue someone shovels back by hand.
Traction names the three sizes after the scope they break: airport (the whole dependency), gate (one work type), and flight (one message). The concepts page has the full vocabulary.
Story one: the CRM sync
Two work types (contact sync, company sync) against one CRM account.
| Airport-sized | Gate-sized | Flight-sized | |
|---|---|---|---|
| What broke | The API token expired | The company endpoint changed its contract | One contact has an invalid email |
| HITS | Every call, both work types | Every company sync; contacts are fine | That one record |
| Naive consumer | Retries all 1,000 messages to the DLQ | Burns company retries, alerts per message | Retries a record that can never succeed |
| RIGHT MOVE | Stop the whole airport on the first failure; everything waits on the runway | Park company syncs, stop that gate after a streak, keep contacts landing | Reject it, keep going, re-drive from the source once the data is fixed |
| You report | AIRPORT_FAULT |
GATE_FAULT |
FLIGHT_FAULT |
| Recovery | Fix the token, one reopen curl | Fix the contract, reopen the gate, replay what parked | Fix the record upstream |
Story two: the bulk email run
The same shapes, again. A welcome-email run of 50,000 messages against one email provider.
| Airport-sized | Gate-sized | Flight-sized | |
|---|---|---|---|
| What broke | The sending account was suspended | The welcome template was deleted | One address is malformed |
| HITS | Every email of every kind | Every welcome email; password resets still send | That one address |
| Naive consumer | 50,000 doomed sends, each retried | Welcome emails fail one by one, for hours | Retries a send that can never succeed |
| RIGHT MOVE | Stop the airport; the run waits, nothing is lost | Stop the welcome gate; other mail keeps flowing | Reject it; the run's tally counts it as rejected |
| You report | AIRPORT_FAULT |
GATE_FAULT |
FLIGHT_FAULT |
| Recovery | Reinstate the account, reopen | Restore the template, reopen, replay | Nothing to replay; fix the list |
Two different vendors, the same three cards. That is the point: the size of a failure is a
property of how your work depends on the vendor, not of the vendor. So it can be declared
once, in an AirportSpec, and acted on by the framework.
And the failures that aren't a size
Some failures say nothing about scope. They say "not right now".
| You report | It means | Traction does |
|---|---|---|
TRANSIENT |
A timeout or 5xx blip | Holds the flight with backoff (30 s, 2 min, 10 min), then parks it |
RATE_LIMIT |
The vendor throttled you | Pauses the airport for one rate-limit window (a ground delay) and requeues the flight; never a failure |
INTERNAL |
Your own bug, not the vendor's | Holds once (30 s), then parks it for a human |
A streak of FLIGHT_FAULTs or INTERNALs (5 distinct records in 5 minutes) also stops the
gate: when every record is "bad", the records probably aren't the problem.
Why this needs a framework
Containment is shared state. A single consumer can't see the pattern alone.
One consumer thread sees one exception. Seeing that 3 different company records failed the same way in 5 minutes, across every pod, takes a shared counter. Stopping every pod at once takes a shared status. Pacing every pod to one vendor quota takes a shared token bucket. Then someone has to notice, notify once (not 1,000 times), and give an operator a way to resume.
That is a lot of plumbing to write per consumer. Traction is that plumbing, written once, on the broker and the Redis you already run. Next: concepts, then the life of a flight.