DEV Community

minia2a
minia2a

Posted on Originally published at minia2a.uk

Your Caller Picks How Many Times Your Loop Runs

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.

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 2^53 — 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×10^18), 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:

stage count
three-part for loops 325
…with a comparison in the test clause 324
…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 reach 71 (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, 10^9 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:

cap ops
200 / 500 / 1,000 / 2,000 isUntouchable, bellNumber, factorialSeries, fibonacciSeries, lucasSeries, catalanSeries, partitionNumber, stirlingSecond
5,000 / 10,000 / 20,000 isHighlyAbundant, lucasNumber, derangementCount, abundantNumbers, deficientNumbers, perfectNumbers
100,000 isSemiperfect, isWeird, isHighlyComposite
10^7 harmonicNumber
10^12 eulerPhi, 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 10^9-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 reachability join is static. If a handler ever dispatched by a computed name — ops[input.op] — the scan would not see that edge. Today no handler does; the check is part of the measurement, not an assumption.
  • Five library functions still contain the old sqrt(x) walk (isPrimeCheck, nextPrimeAfter, prevPrimeBefore in ops12; isPrime, nextPrime in ops). No service dispatches to those names, so they are latent, not live. We wrote them down rather than declaring the file clean.
  • A cap is a contract change. Refusing x > 1012 means an answer that used to exist now does not. Where that happens the error message has to say the number, so the caller can tell “too large for this method” from “invalid”.

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.


Originally published at minia2a.uk.

Top comments (0)