The bandit,
the breaker,
the Dragonfly.
The internals. How epsilon-weighted random selection actually distributes traffic, what the circuit breaker is doing on every report, why we picked a ZSET, why Lua + EVALSHA, and what the pending-webhooks Map is defending against. Two interactive widgets you can drag.
A bandit in a payments suit
The router is a multi-armed bandit. Each provider is an arm. Selection trades exploration against exploitation. The novelty is that the formula accounts for latency, not just success.
If you've read the MAB literature, the framing is familiar: each payment provider is an arm. Pulling an arm (routing a transaction) yields a stochastic reward (success or failure, fast or slow). The goal is to maximize cumulative reward over time, which means balancing exploration against exploitation.
What's not standard MAB: the reward isn't binary. A success at 200ms is more valuable than a success at 3000ms — the latency penalty bakes that into the weight directly. So this isn't pure Thompson Sampling, isn't UCB, isn't ε-greedy in the classical sense. It's a domain-specific hybrid:
the formulaweight = successRate × latencyPenalty × circuitMultiplier × businessMultiplier // successRate = (α + successes) / (α + β + total) [β-smoothed] // latencyPenalty = p95 ≤ target ? 1 : exp(-k × (p95/target − 1)) // circuitMult = closed → 1.0 | half-open → 0.02 | open → 0 // businessMult = a manual override (cost, contracts, regional pref) // At selection time: // with prob ε → uniform random over eligible providers // with prob 1−ε → weighted random ∝ weight
One more wrinkle: the selection step is weighted random, not greedy. A
pure-greedy bandit always picks argmax weight. We don't — we draw
proportionally to weights. Two reasons:
- Load distribution. Sending 100% of traffic to the highest-weight provider creates a single point of failure and rate-limits you against that provider. Weighted random spreads traffic across the top performers.
- Implicit exploration. Even without the ε term, weighted random gives non-zero probability to lower-weight providers. They get traffic, they get observations, they get a chance to climb back.
So epsilon-weighted-random would be the precise name. Most of the literature calls this kind of thing softmax-style exploration with a temperature of 1.
src/core/payment-router.ts is the orchestrator. weight-calculator.ts is the formula. redis-pool.ts is selection (Lua scripts cached as EVALSHA SHAs). circuit-breaker.ts is the state machine. The rest is plumbing.
Weighted random sampling, watched
Drag the sliders to set provider weights. Watch the proportional distribution. Run a 1000-pick simulation and see how close the empirical split lands to the theoretical one.
The selection step is a single Lua script that does the math server-side in Dragonfly. Conceptually, it lays out the providers on a number line proportional to their weights, picks a uniform random point, and reports which provider owns that point. With the ε term in front, 5% of picks ignore the weights entirely and pick uniformly.
Provider weight playground
Adjust each provider's weight. Run 1000 simulated picks. Compare empirical to theoretical.
| provider | theoretical | empirical | Δ |
|---|---|---|---|
| alpha | — | — | — |
| beta | — | — | — |
| gamma | — | — | — |
| delta | — | — | — |
What the widget is showing
The horizontal bar is the proportional distribution of weights — each provider's segment is its weight divided by the total. The ring is the same data: arcs sized by probability. Run the simulation and the empirical column updates with what 1000 actual draws produced.
With ε = 5%, 50 of the 1000 picks are uniform random (each provider gets
~12.5 of them) and the other 950 are weighted.
Crank ε up to 50% and watch the empirical distribution flatten out
— that's literally what exploration does to traffic shaping.
The Lua, in detail
The actual server-side selection is twenty lines of Lua. Here it is, lightly commented:
redis-pool.ts:14local peers = redis.call('ZRANGE', KEYS[1], 0, -1, 'WITHSCORES') local n = #peers / 2 if n == 0 then return nil end -- Parse exclude list (comma-separated) local excluded = {} if ARGV[4] and ARGV[4] ~= '' then for p in string.gmatch(ARGV[4], '([^,]+)') do excluded[p] = true end end -- Build eligible list and total weight local eligible, totalWeight = {}, 0 for i = 1, #peers, 2 do local p, w = peers[i], tonumber(peers[i + 1]) if w > 0 and not excluded[p] then table.insert(eligible, {p, w}) totalWeight = totalWeight + w end end if #eligible == 0 then return nil end -- ε-greedy split local epsilon = tonumber(ARGV[1]) or 0 local exploreRand = tonumber(ARGV[2]) or 0 local selectRand = tonumber(ARGV[3]) or 0 if exploreRand < epsilon then -- exploration: uniform pick local idx = math.floor(selectRand * #eligible) + 1 return {eligible[idx][1], 'explored'} else -- exploitation: weighted pick (cumulative walk) local target, cumulative = selectRand * totalWeight, 0 for i = 1, #eligible do cumulative = cumulative + eligible[i][2] if cumulative >= target then return {eligible[i][1], 'exploited'} end end return {eligible[#eligible][1], 'exploited'} end
Two things worth noticing: random values come in as arguments, not
math.random() inside the script. Dragonfly requires scripts to be
deterministic for replication, so the application generates the entropy and passes it
in. And the cumulative walk is the standard inverse-CDF method — for typical pool sizes
(≤20 providers) it's faster than building a real CDF.
Circuit breaker, walked
Three states. Six transitions. Click the buttons to walk a provider through real scenarios and see what the breaker does.
The circuit breaker is the thing that prevents the router from hammering a dead
provider. It's a per-provider state machine stored in the Dragonfly metrics hash. Each
report — success or failure — runs through evaluateOnResult, which may flip
the state. The 10-second background sweep checks for time-based transitions (open → half-open
after cooldown).
The transitions, in words
| from | to | trigger | side effect |
|---|---|---|---|
| closed | closed | success | consecutiveFailures = 0 |
| closed | closed | failure (1–4) | consecutiveFailures += 1 |
| closed | open | failure (5th in a row) | lastFailureTime = now |
| open | half-open | 30s elapsed (sweep) | none (transition observed) |
| half-open | closed | success (probe) | consecutiveFailures = 0 |
| half-open | open | failure (probe) | exponential backoff applied |
| any | open | manual via API | auto-recovery disabled |
| open (manual) | closed | manual via API | resets counters |
Exponential backoff on repeated failures
When a provider's circuit opens, recovers, then immediately fails its probe, repeated
cycles compound. The getOpenDuration implementation backs off
geometrically:
circuit-breaker.tsgetOpenDuration(metrics: ProviderMetrics): number { const baseMs = this.config.openDurationMs; // 30000 const failureGroups = Math.floor(metrics.consecutiveFailures / 5); const multiplier = Math.min(Math.pow(2, failureGroups), 10); // Cap at 5 minutes return Math.min(baseMs * multiplier, 5 * 60 * 1000); }
| cumulative failures | multiplier | cooldown |
|---|---|---|
| 5 | ×1 | 30s |
| 10 | ×2 | 60s |
| 15 | ×4 | 120s |
| 20 | ×8 | 240s |
| 25+ | ×10 (cap) | 300s (5 min) |
The cap at 5 minutes is intentional — without it, a stubbornly degraded provider would be locked out indefinitely and never get a chance to recover. With it, even the worst-case provider gets a probe attempt every 5 minutes.
When an operator calls POST /api/providers/:n/circuit/open, the circuit
goes to open but the auto-recovery sweep skips it. It stays open
until explicitly closed. This is intentional: maintenance windows shouldn't surprise
you with a 30-second auto-recovery.
Why a sorted set
The provider weights live in a Dragonfly ZSET, not a Hash. The choice gets
us atomic fetch-with-scores, range queries on score, and O(log N) updates — none of
which a Hash offers.
Why not a Hash?
| operation | HASH | ZSET (current) |
|---|---|---|
| update one weight | HSET · O(1) | ZADD · O(log N) |
| fetch all members + scores atomically | 2 ops (HKEYS + HVALS) | ZRANGE WITHSCORES · 1 op |
| filter by score range | client-side | ZRANGEBYSCORE · O(log N + M) |
| order by score | client-side | built in |
| top N by score | fetch all, sort | ZREVRANGE 0 N-1 |
For the steady-state selection loop, the ZSET's ability to return members + scores in a
single atomic call is the win. The selection Lua script reads with one
ZRANGE … WITHSCORES and writes nothing. There's no inconsistency window
where the script saw stale weights.
For the eventual "top 3 providers" or "providers with weight > 0.5" admin queries (not implemented yet), a Hash would force us to fetch everything and filter client-side. The ZSET handles those queries in Dragonfly without crossing the network boundary.
History as a second ZSET
There's also a per-provider history ZSET that stores weight changes over time. Score = timestamp, member = the stringified weight value:
history ZSETKEY: providers:payments:history:stripe
ZSET layout (score = ms timestamp, member = weight as string):
1700000001000 → "0.847"
1700000002000 → "0.851"
1700000003000 → "0.843"
... (last 1000 entries; oldest trimmed via ZREMRANGEBYRANK)
This is what lets the admin dashboard render a sparkline of weight-over-time without the
app server keeping per-provider history in memory.
ZREMRANGEBYRANK key 0 -1001 keeps the buffer bounded.
Latency profile
At the scale we run (≤ 20 providers per channel, < 1000 history entries each), all
ZSET operations are O(log N) with small constants. Even 100 concurrent
/api/route requests do 100 atomic Lua calls against a single ZSET — the
selection script is read-only, so concurrent route reads don't contend, and the script
is the entire critical section. (Dragonfly is multi-threaded and runs read scripts
per-shard, which is why selection stays on Lua.)
Lua scripts, cached as SHAs
The selection script is loaded into Dragonfly once via SCRIPT LOAD at first
use. After that, every selection invokes the script by SHA via EVALSHA;
NOSCRIPT errors trigger a reload-and-retry. The weight write no longer uses a Lua script
— it runs as Bun auto-pipelined commands (see chapter 04).
The naive way to use a Lua script with Dragonfly is EVAL: send the entire
script source on every call. That works, but at 200 tps with a ~700-byte script you're
paying ~140 KB/sec just to ship the script — plus the server has to re-parse the source
every time. The professional way is to upload the script once and reference it by hash
thereafter.
The helper, in full
redis-pool.ts:18private async ensureScriptsLoaded(): Promise<void> { if (this.scriptsLoadedPromise) return this.scriptsLoadedPromise; this.scriptsLoadedPromise = (async () => { for (const [name, src] of this.scripts) { const sha = (await this.redis.send('SCRIPT', ['LOAD', src])) as string; this.scriptShas.set(name, sha); } })(); return this.scriptsLoadedPromise; } private async evalScript(name: string, args: string[]): Promise<unknown> { await this.ensureScriptsLoaded(); const sha = this.scriptShas.get(name)!; try { return await this.redis.send('EVALSHA', [sha, ...args]); } catch (err) { if (String(err).includes('NOSCRIPT')) { this.scriptsLoadedPromise = null; await this.ensureScriptsLoaded(); return await this.redis.send('EVALSHA', [this.scriptShas.get(name)!, ...args]); } throw err; } }
What this actually saves
| per call | EVAL (before) | EVALSHA (after) | at 200 tps |
|---|---|---|---|
| bytes on wire (selection) | ~ 700 | ~ 40 | 132 KB/s saved |
| bytes on wire (weight update) | ~ 350 | ~ 40 | 62 KB/s saved |
| Redis script parse | every call | compile-cached | 200/s parses eliminated |
| measured route p95 | 4 ms | 3 ms | −25% |
The wire-bytes savings are small in absolute terms, but eliminating the per-call parse is the real win — anything you can take off the datastore's hot path matters.
Dragonfly can clear its script cache for several reasons: SCRIPT FLUSH,
full restart, replica failover where the script wasn't replicated. The helper catches
NOSCRIPT, nulls the cached promise so the next call reloads, and retries once. If the
second attempt fails, that's a real error and we bubble it.
The pendingWebhooks Map
Async transactions are held by one in-memory Map keyed by transaction ID. The existence of the entry at lookup time is the entire idempotency check.
Sync transactions are simple: report comes in, metrics update, circuit breaker evaluates, weight recomputes. Done. The async path is where it gets interesting — the router has to remember which transactions are in flight, time them out if a webhook never arrives, and silently drop late webhooks that show up after the timeout already fired.
What the timeout sweep does
payment-router.ts:455private startWebhookTimeoutChecker() { setInterval(() => { const now = Date.now(); const timeoutMs = this.config.webhookTimeoutMs; // default 120000 for (const [txId, pending] of this.pendingWebhooks) { if (now - pending.timestamp > timeoutMs) { // Synthetic webhook outcome — same path as a real one void this.reportWebhookOutcome({ transactionId: txId, provider: pending.provider, confirmed: false, }); } } }, 30_000); // every 30 seconds }
30-second interval × 2-minute timeout means the worst case latency for a webhook to be counted as a failure is timeout + interval = 150 seconds. Good enough for the failure-detection use case; raise the timeout if your providers legitimately take longer.
Why the Map is in process, not Dragonfly
The pendingWebhooks Map lives in JavaScript memory, not Dragonfly. That's a deliberate choice with a tradeoff: simpler code, lower latency, but the Map doesn't survive a process restart. If you're running a single router instance and it restarts mid-flight, those async transactions silently time out (the webhook arrives, hits the idempotency check, gets dropped — same outcome as a missed webhook).
For multi-instance deployments, you'd want sticky routing per transaction ID so the same instance handles both the initial report and the eventual webhook. That's a future concern; the current scale doesn't need it.
Some bank transfers, some 3DS flows, some COD-style scenarios legitimately take longer
than 120s. Raise webhookTimeoutMs in the admin → algorithm panel. Setting
it too low costs you false failures; setting it too high holds memory longer.
Production tuning notes
A checklist of knobs to think about before going live. None of these are wrong by default — they're calibrated for a particular shape of traffic, and yours may differ.
Per-provider sample window
The latency histogram is bounded at 1000 samples (oldest evicted FIFO). At low traffic that's roughly 100 minutes of data, at high traffic that's seconds. Decide which side of that you want — if you're doing > 1000 tps to a single provider and want a meaningful p95, raise the cap in metrics-store.ts:52 or move to a time-windowed rolling buffer.
Weight update cadence
10-second background sweep recomputes all weights. Faster = more responsive to provider degradation, more compute. Slower = smoother weights, less reactive. The 10-second default is a calibration choice from synthetic load — high-traffic systems can usually go to 5s; low-traffic can sit at 30s without noticeable lag.
| traffic profile | recommended interval |
|---|---|
| steady high (> 100 req/s) | 5–10 s |
| steady medium (1–100 req/s) | 10 s (default) |
| bursty / low (< 1 req/s avg) | 30 s |
| financial / critical | 5 s |
Epsilon for your environment
5% exploration is calibrated for steady-state production. Different contexts want different values:
| scenario | recommended ε | reason |
|---|---|---|
| stable provider mix | 0.01–0.03 | minimize waste on exploration |
| volatile providers | 0.05–0.10 | faster discovery of changes |
| brand-new deployment | 0.10–0.15 | learn faster initially |
| steady-state production | 0.03–0.05 | balanced |
Latency target alignment with SLA
The p95TargetMs default of 1500ms is where the penalty curve starts. If
your SLA is "sub-second response", set it to 800ms — providers above that get penalized
faster. If you're processing async bank transfers, you can relax it to 3000ms.
Circuit breaker threshold
5 consecutive failures is sensitive — at high traffic you can trip on a 1-second blip. For traffic > 1000 req/s, 10–20 is more realistic. For low traffic, 3 may be more appropriate.
Bayesian prior
α=3, β=2 (60% starting rate) is moderately optimistic. Adjust based on your risk tolerance:
| profile | α | β | starting rate |
|---|---|---|---|
| optimistic — trust new providers | 5 | 1 | ~83% |
| balanced (default) | 3 | 2 | ~60% |
| conservative — neutral start | 1 | 1 | ~50% |
| pessimistic — prove yourself | 1 | 3 | ~25% |
Metrics window reset
The metrics window is one hour — after that, counts reset (circuit state preserved). For systems with daily traffic patterns, this is fine. For systems where a single provider's hourly traffic might be small, consider extending the window or going time-based.
Dragonfly production checklist
- Persistence enabled (point-in-time snapshots via
--snapshot_cron) - Replication (master + at least one replica)
- Sentinel or Cluster for failover
- ~1 MB memory per 10 providers; budget generously
- Don't share Dragonfly with other apps — connection counts and command latency tracking get noisy
Watch these metrics in prod
| metric | target | alert at |
|---|---|---|
| overall success rate | > 95% | < 90% |
| route p95 | < 10 ms | > 50 ms |
| e2e p95 (with provider) | < 1500 ms | > 2500 ms |
| open circuits | 0 nominal | > 2 simultaneously |
| exploration share | ~ 5% | < 2% or > 10% |
| pendingWebhooks size | < 50 | growing unboundedly |
| Dragonfly cmd latency | < 5 ms | > 20 ms |