📅 Historical page. This content reflects minia2a as of its publication date and is kept for the record. Current model: x402 pay-per-call in USDC on Base only, 5 free trial calls per signed wallet, no credits and no top-up rail. → See current

Your Caller Picks How Many Times Your Loop Runs

2026-11-06 · measurement + guard

On a single-threaded API server, a loop bound is not a local variable. It is the amount of the whole service the caller gets to occupy. A pay-per-call endpoint can charge as little as a cent and then runs your code on the same event loop that answers every other request, so a bound taken from the request body is a resource allocation decision made by someone you have never met.

This is not theoretical here. On 2026-11-04 and 2026-11-05 the same shape fired three times: a primality helper walked trial division to sqrt(x) with x straight from pin(). At x = 9007199254740881 — a legal safe integer, below 253 — one call held the sidecar for 1,028 ms in x402-prime and 1,026 ms in x402-is-prime, and 410–845 ms in nine library copies of the same predicate. Nine endpoints, one input, one thread.

What we measured

The fix for that family was a shared deterministic Miller-Rabin (12 bases, proven exact below 3.3×1018), and it dropped those calls under a millisecond. But it left a question we had never answered: how many other loops in the deployed code take their bound from the caller?

So we enumerated. On the deployed bytes of the sidecar — 1,711 handler files and 28 library modules — we extracted every three-part for (init; test; update) after stripping comments and string/template contents:

stagecount
three-part for loops325
…with a comparison in the test clause324
…where the right-hand side is a variable expression (not a literal, not .length/.size, not an all-caps constant)163
…inside a function a published endpoint can actually reach71 (across 59 ops)

The last row is the one that matters. Most handlers are thin wrappers that call one named function in a library module, so the join is mechanical: require('../lib/opsN') + ops['name'] gives the exact function, and we scanned only those function bodies. A loop in a library function nobody dispatches to cannot block a caller, and counting it would inflate the number without changing the risk.

What came back

Each of the 71 sites got an extreme input in its own Node subprocess with a hard timeout: the largest safe integer for value-shaped parameters, 109 for count-shaped ones. Not a static judgement about whether the code “looks” bounded — the deployed function was executed and timed.

Result: 0 hangs, 0 slow calls, every answer under 30 ms. Of the 59 ops, 39 refused the extreme input outright with a BadRequestError that names the cap:

capops
200 / 500 / 1,000 / 2,000isUntouchable, bellNumber, factorialSeries, fibonacciSeries, lucasSeries, catalanSeries, partitionNumber, stirlingSecond
5,000 / 10,000 / 20,000isHighlyAbundant, lucasNumber, derangementCount, abundantNumbers, deficientNumbers, perfectNumbers
100,000isSemiperfect, isWeird, isHighlyComposite
107harmonicNumber
1012eulerPhi, eulerTotient, carmichaelFunction, isPractical, isSmith, isSmithNumber, primeFactors

Two numeric solvers take an iteration count from the caller and returned in 13 ms at iters = 109, because they exit on tolerance long before the count is consumed. That is a different mechanism from a cap — and just as fragile: remove the early exit and the same call becomes a 109-step walk.

Why a 20 ms reading is still worth pinning

A measurement that is green today says nothing about tomorrow. Every one of those caps and early exits is a line of code someone can delete in a refactor, and when they do, the endpoint does not return an error — it returns slowly, or it does not return, and the sidecar stops answering anything else.

So we moved 21 of the cases into the daily freeze table (tools/op-freeze-guard.py): each is the exact input that would walk the loop if the cap disappeared, with a budget of 100 ms. A reverted cap now lands as SLOW or HANG on that table instead of as a quiet regression. The table went from 39 to 60 cases, and it is run every day against the deployed bytes, not against a source mirror.

What this does not prove

Three honest limits:

The rule we settled on

If a loop bound can come from the request, it is part of the API contract. Either cap it and name the cap in the refusal, or derive it from a structural quantity the input cannot inflate — string length, board size, a fixed table. “It terminates” is not a bound; O(sqrt(n)) at n = 253 is 95 million iterations on a thread that is serving everyone else.

None of this is exotic. It is the same one-line check most services skip because the happy-path input is small. The difference is that on a metered, single-threaded endpoint, the cost of skipping it is not a slow request — it is everyone’s request.


Measured on the deployed minia2a sidecar, 2026-11-06. Catalog size at measurement time: 1,841 catalog entries, 1,685 of them first-party (/api/stats). The extraction, the extreme inputs and the freeze-table rows are in tools/op-freeze-guard.py and the deployed-byte analysis that produced them.