Why it works.
And how to run it
for real.
The algorithm rationale DOC leaves out — why this is Beta-mean selection and not true Thompson Sampling — plus the production-readiness checklist, deployment, and the Google OAuth story. Play with the bandit before you read about it.
Why this algorithm — and why it isn't Thompson Sampling
The router uses one strategy: epsilon-weighted-random selection over Beta-smoothed success rates. It borrows the Bayesian prior but deliberately skips the sampling step.
Every provider's weight is the mean of a Beta distribution over its observed successes and failures — not a random sample drawn from it. That one choice is what separates this from textbook Thompson Sampling, and it's deliberate.
Drag the sliders below. Each provider's curve is its belief distribution; the dashed line is the mean used as its weight. Then hit simulate and watch how often each provider gets picked under both strategies — they converge.
Slider = successes out of 100 trials. Weight = (3+s)/(5+100). ε = 5% uniform.
The weight formula
The mean is only the first of four factors. The full weight, computed in weight-calculator.ts, is:
weight-calculator.ts// 1 — Beta mean (Bayesian success rate) successRate = (α + successes) / (α + β + total) // α=3, β=2 // 2 — latency penalty (slower ⇒ lower) latencyPenalty = (p95Target / actualLatency) ^ k // p95=1500ms, k=1.2 // 3 — circuit multiplier circuitMult = open ? 0 : (halfOpen ? 0.02 : 1) // 4 — business multiplier (operator knob) finalWeight = clamp(successRate × latencyPenalty × circuitMult × businessMult, minWeight, maxWeight) // 0.001 … 1000
Selection: exploit 95%, explore 5%
Weights only rank providers — they don't pick one. Selection is epsilon-greedy, and it runs inside the Dragonfly Lua script so it stays atomic:
selection (Lua)if random() < epsilon then -- 5% exploration selected = uniformRandom(eligible) else -- 95% exploitation selected = weightedRandom(providers, weights) end
Where it sits among the alternatives
| approach | exploration | adapts? | cost |
|---|---|---|---|
| Round-robin | none | never | very low |
| Random | uniform | never | very low |
| This · Beta + ε-greedy | 5% uniform | fast, continuous | medium |
| True Thompson Sampling | probability matching via sampling | fast | high |
| UCB | optimism under uncertainty | fast | medium |
Thompson Sampling draws a fresh random sample from each provider's Beta distribution on every request — more math per route. This router takes the distribution's mean instead: cheaper, deterministic for Dragonfly replication, and exploration is handled explicitly by the ε knob rather than implicitly by sample variance. As the simulator above shows, the selection shares land in nearly the same place for this use case.
Production-readiness checklist
The implementation covers the core algorithm. These are the items the WIKI flags as "before you put real money through it." Tick them off — your progress is saved locally.
First, the seven properties that make the core approach work — keep them in mind as you harden the rest:
- Bayesian priors — starts at a sensible 60%, not a coin-flip 50/50.
- Weighted random selection — proportional to performance, never deterministic.
- Epsilon-greedy exploration — 5% keeps discovering recoveries.
- Latency awareness — speed matters, not just success rate.
- Circuit breakers — stop wasting attempts on dead providers.
- Real-time adaptation — weights move after every reported outcome.
- Stateful — Dragonfly persistence means the router survives restarts.
The hardening checklist
0 / 12 0%
Idempotency on the webhook path, valibot input validation, the audit
config_history table, and Google OAuth on /admin are in the
box today (see chapters 03–04). The checklist is the gap between "demo-correct" and
"money-correct."
Deployment
Two stateful dependencies (SQLite file + Dragonfly, a Redis-compatible store), a handful of env vars, and a two-service Docker Compose. The cache layer is the only subtle part.
Environment variables
| var | purpose | example / note |
|---|---|---|
| DATABASE_URL | SQLite source of truth | ./sqlite/data.db |
| REDIS_URL | cache + hot metrics | redis://localhost:6379 |
| BETTER_AUTH_SECRET | session signing key | min 32 chars |
| BETTER_AUTH_URL | canonical app URL | must match deployment origin |
| GOOGLE_CLIENT_ID | OAuth client | from Google Cloud Console |
| GOOGLE_CLIENT_SECRET | OAuth secret | from Google Cloud Console |
| ALLOWED_EMAILS | admin whitelist (optional) | a@x.com,ops@x.com |
Docker Compose
docker-compose.ymlservices: app: build: . ports: ['3000:3000'] environment: - DATABASE_URL=/app/data/payment-router.db - REDIS_URL=redis://redis:6379 volumes: ['./data:/app/data'] depends_on: [redis] redis: image: docker.dragonflydb.io/dragonflydb/dragonfly:latest volumes: ['redis-data:/data']
First-run, locally
terminalbun install bun run better-auth-gen-schema # generate the auth tables bun run db:migrate:local # apply migrations to SQLite bun run dev # :3000 bun run db:studio:local # optional: browse the DB
The cache contract: read-through, write-invalidate
core/config-store.ts treats SQLite as the source of truth and Dragonfly as a 300-second cache. Reads hit Dragonfly first; writes go to SQLite then delete the cache key so the next read repopulates it. This is the one place a stale read could bite, so the invalidation is explicit.
core/config-store.tsasync getProviders() { const cached = await redis.get('config:providers'); if (cached) return JSON.parse(cached); // read-through const rows = await db.select().from(providerConfig); await redis.setex('config:providers', 300, JSON.stringify(rows)); return rows; } async updateProvider(name, cfg) { await db.update(providerConfig).set(cfg).where(eq(providerConfig.name, name)); await redis.del('config:providers'); // write-invalidate await logChange('provider', name, cfg); // audit trail }
Auth & security
Only /admin/* is protected — via Google OAuth through better-auth, with an
optional email whitelist enforced at the database hook. The /api/* surface
is open by design.
The OAuth round-trip
BYPASS_AUTH=true skips the whole loop.
Auth endpoints
routesGET /api/auth/sign-in/social # start Google OAuth GET /api/auth/callback/google # OAuth callback POST /api/auth/sign-out # sign out GET /api/auth/session # current session
Email whitelist
Enforced in a better-auth database hook so it runs for every sign-up, regardless of
entry point. Leave ALLOWED_EMAILS empty to allow any Google account.
src/lib/auth.tsdatabaseHooks: { user: { create: { before: async (user) => { const allowed = process.env.ALLOWED_EMAILS?.split(',') || []; if (allowed.length > 0 && !allowed.includes(user.email)) { throw new Error('Email not authorized for admin access'); } return user; } } }, }
Security posture
| control | status | how |
|---|---|---|
| Admin auth required | in box | middleware on all /admin/* |
| CSRF protection | in box | built into better-auth |
| Session management | in box | secure, HTTP-only cookies |
| Input validation | in box | valibot schemas, server-side |
| Audit logging | in box | user + timestamp + diff per change |
| API rate limiting | consider | not implemented — add per provider |
Troubleshooting
SSE updates not arriving
Check the browser console for connection errors, confirm /admin/events is
reachable, and make sure no proxy is buffering the SSE response.
Google OAuth fails
Verify GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET, that the
redirect URI is registered in Google Cloud Console, and that
BETTER_AUTH_URL matches the deployment origin exactly.
Config changes don't persist
Check the SQLite file permissions, confirm Dragonfly is connected, and scan the app logs for database errors.