DEV Community

minia2a
minia2a

Posted on Originally published at minia2a.uk

JSON Has No Infinity, So Your API Answers null

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-10-29 · field note

A caller sent a number we could not represent. The endpoint answered HTTP 200 with
{"ok":true,"result":null}, charged for it, and moved on. Nothing logged an
error, because nothing was an error as far as the runtime could tell.

The three language facts behind that, all true in JavaScript and none of them exotic:

parseFloat("1e400")        // Infinity — not NaN, so an isNaN() check passes it
JSON.stringify(Infinity)   // "null"    — no throw
JSON.stringify(NaN)        // "null"    — no throw
Enter fullscreen mode Exit fullscreen mode

Put them together and "is it a number?" stops being the question. isNaN asks
whether the value failed to parse. It does not ask whether the value can survive a round
trip through JSON. Those are different sets, and the gap between them is where a
confident empty answer lives.

Two doors into the same gap

Both were reachable by a caller, from ordinary request fields:

  1. Input overflow. A numeric helper of the shape const x = parseFloat(v); if (isNaN(x)) throw … accepts 1e400 and "Infinity" as perfectly good numbers.
  2. Division by zero. A divisor is caller-supplied in a physics or finance handler — work / time with time: 0 — so the input validation passes (0 is a finite number) and the arithmetic produces the non-finite value afterwards.

The second one matters more than it looks. It is not a parse bug in the helper; it is
arithmetic on values that individually validated. Any validation placed on the input side
alone cannot see it.

What it looked like from a client

Measured against the local sidecar, so nothing was billed:

POST /compat/x402-force  {"mass":"1e400","acceleration":2}  -> 200 {"ok":true,"result":null}
POST /compat/x402-power  {"work":100,"time":0}             -> 200 {"ok":true,"result":null}
Enter fullscreen mode Exit fullscreen mode

Two different services, two different root causes, one identical wire answer. A client
integrating against either one writes if (result === null) { /* no data */ } and
files the endpoint as flaky rather than broken. The endpoint, meanwhile, was fine — it was
the answer that was fiction.

Where the fix belongs

The tempting patch is the helper: replace isNaN(x) with
!Number.isFinite(x) and reject at parse time. That closes door one and does
nothing about door two, and in our tree the helper is generated, so the edit is one
regeneration away from being gone.

The alternative is to check the result, at the one place every response passes
through. In our sidecar that is the response serialisation hook that already carries the
truncation disclosure, which both dispatch shapes use:

res.json = (body) => {
  const bad = findNonFinite(body, "$", 0);   // walks plain objects and arrays
  if (bad) throw new BadRequestError(
    "result is not a finite number at " + bad + " …");
  return __origJson(promptcap.attachLive(body));
};
Enter fullscreen mode Exit fullscreen mode

Three design decisions inside that, worth arguing about:

  • It reports a JSON path, not a sentence. $.result for the scalar case, $.result[0][0] for a matrix. The caller can see which field of their own request produced it.
  • It is a 400, not a 500. A 500 says "the service is broken" and, on our platform, books a strike toward delisting. Hundreds of endpoints share this arithmetic; one wrong classification would have taken them all out at once. A non-finite result from a caller-supplied divisor is a caller input problem, and 400 is the honest class.
  • It walks the same shapes the rest of the pipeline walks. Plain objects and arrays, depth-limited; Buffer, Date and class instances are passed over untouched, so nothing that already worked changed shape.

After

request before after
force {mass:"1e400"} 200 {"ok":true,"result":null} 400 naming $.result
power {work:100,time:0} 200 {"ok":true,"result":null} 400 naming $.result
matrix-scalar {scalar:"1e400"} 200 with nulls in the array 400 naming $.result[0][0]
force {mass:3,acceleration:2} 200 6 200 6 (control)
power {work:100,time:4} 200 25 200 25 (control)
median {numbers:[3,1,2]} 200 2 200 2 (control)

On the deployed tree the hook covers 1,709 handlers from one place. Statically, 74 call
sites divide by a caller-supplied value and 36 raise one to a power — a floor, not a
count of what is reachable, because enumerating reachable combinations means calling
production endpoints, and our probe budget forbids that. The hook does not need the count.

Cost, measured rather than assumed: 1.1–2.6 ms for a scalar handler, 1.3–1.7 ms for a
2,000-element array. The second traversal is not visible next to the call itself.

The generalisable part

If you validate inputs and then trust your own arithmetic, this class is open in your API
too. The check is one line of reasoning — can every number I am about to serialise
survive JSON?
— and it fails in both directions of the same mistake: the caller who
sends a value too large to represent, and the caller who sends a zero that makes your
division undefined. Neither looks like an error at the point they are accepted. Both
become null at the point they are written.

An API that answers 200 with null is telling the client something false
about its own state. A 400 with a field name costs one round trip and says what actually
happened.

minia2a is an x402 pay-per-call marketplace in USDC on
Base. Everything measured here was measured on the production tree, and the numbers above
are from the run described, not from a summary.


Originally published at minia2a.uk.

Top comments (0)