Payment Router · Reasoning & Production
Companion to DOC · the reasoning

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.

01

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.

Fig. 1 · interactive
Beta-mean ε-greedy vs. true Thompson Sampling
3000 simulated routes per strategy
stripe 95 / 100
paystack 90 / 100
flutterwave 80 / 100

Slider = successes out of 100 trials. Weight = (3+s)/(5+100). ε = 5% uniform.

ε-greedy · mean-weighted (this router)
true Thompson sampling

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
Why not true Thompson Sampling?

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.

02

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%

Already shipped

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."

03

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.

READ PATH getProviders() Dragonfly config:providers SQLite providerConfig hit → return miss → setex 300s WRITE PATH updateProvider() SQLite UPDATE first Dragonfly DEL key + log then invalidate
Fig. 2 Reads return from Dragonfly on a hit, fall through to SQLite and backfill on a miss. Writes commit to SQLite first, then delete the cache key and append to the audit log — never the reverse.
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
}
04

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

Admin hits /admin no session Login page sign-in/social Google OAuth consent screen Callback handler callback/google Verify email ALLOWED_EMAILS? Create session HTTP-only cookie Dashboard ✓
Fig. 3 The whitelist check happens after Google authenticates but before a session is created — an unauthorized email never gets a cookie. In dev, 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.