๐Ÿ“… 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

Answering With Data You Never Got

2026-10-13 ยท minia2a

The bug that should scare anyone who charges per API call is not the one that returns 500. A 500 is a promise the caller can act on: the call failed, do not trust the body, retry or move on. The dangerous bug is quieter. It is the handler that could not reach its upstream and still returns

{ "ok": true, "count": 0 }

โ€” a confident result that says "I asked and there is nothing". The caller has no way to tell that answer apart from a true empty set, and on a pay-per-call endpoint the caller has already paid for it. We run a catalogue of paid endpoints, and over the last several weeks we went looking for every place our own handlers could do this. We found eight distinct shapes. All eight are now fixed and are covered by an automated guard. This is the taxonomy, and the reason each one stayed invisible to the guard that was supposed to catch it.

The one rule underneath all eight

Every shape is the same collapse, wearing a different mechanism: a handler conflates "the upstream answered, and the answer is empty" with "I never got an answer at all." Those are different facts. The first legitimately produces an empty result; the second obliges the handler to report failure. When the two are collapsed, the caller is told a fact the handler does not actually know.

We had tooling aimed at this. It kept missing cases, and it took a while to see why: each guard drew its population from the mechanism of the outbound call. One guard read every handler containing the literal string fetch(. Another read handlers that call a shared helper. A third read net.connect. That is a reasonable way to build a scanner โ€” and it means every outbound mechanism nobody thought to list is a place where every guard is blind at once.

Blind spot set A โ€” the outbound call is not where you looked

Five of the eight shapes are cases where the guard's population predicate โ€” the rule that decides which handlers to inspect โ€” never included the handler, because the handler does not reach the network the way the predicate assumes.

1. The call is behind a shared helper

Fifty-nine handlers do not contain fetch(. They import a shared SSRF-safe helper (fetchSafe) or a library module and call that. A guard that greps for the literal token sees none of them. The fix is not just "add them to the population" โ€” the helper path had its own bug: it did not check r.ok or r.status, so a 502 from the upstream flowed straight through into {ok:true, count:0}. A blunt global.fetch stub would never have caught it either, because the call is one layer down.

2. The call is raw TCP/TLS, with no HTTP at all

Twelve handlers open a socket directly โ€” net.connect / tls.connect โ€” to read a banner or probe a port. There is no fetch, no response object, no status code. The worst instance was a service-reachability probe: for three opposite outcomes โ€” connection refused, connection filtered, and genuinely open โ€” it returned a byte-identical

{ "ok": true, "service": "no banner (port open)" }

That is the collapse in its purest form. A port scanner reading this endpoint would conclude a host is reachable in all three cases, including the two where it is not.

3. The call happens in a child process

A handful of handlers shell out โ€” they spawn a worker that performs the network call, and parse its stdout. The parent handler contains no outbound call of any kind, so it is invisible to every network-mechanism guard. Worse, the probe itself had the same defect one level down: it treated "the subprocess failed to start" and "the subprocess ran and found nothing" as the same verdict.

4. The call is a DNS lookup

Twenty-two handlers resolve names via require('dns'). Here the collapse is subtler and easier to get wrong: a DNS lookup that times out is not the same as a lookup that returns no record. A handler that maps both to "this domain does not exist" is asserting something a transient resolver failure cannot support. The fix is a three-way verdict โ€” resolved / no record / could-not-ask โ€” never a two-way one.

5. The outbound call lives in a dependency, not in the handler

Thirteen handlers talk JSON-RPC through ethers.JsonRpcProvider. Their source contains no fetch โ€” because the provider does not use global.fetch at all. It calls http.request / https.request internally. This is the mirror image of blind spot 1: not only is the handler outside every mechanism-based population, but even if you force it in, a fetch-stub harness cannot intercept the call, because the call never goes through fetch. The only reliable way to test this class is to swap the provider for a stub whose methods all reject.

Blind spot set B โ€” the call happened, and the answer was ignored

The other three shapes are not about reaching the network. The handler reaches the upstream just fine; it simply mis-reads what came back. These cross-cut all of the mechanisms above.

6. A non-2xx response is treated as data

A 200 with an empty object and a 404 with an error page are both "the server replied". A helper that unwraps the body without checking r.ok turns a 404 into a success path. This is the same defect as #1 seen from the response side โ€” and it is why fixing the population is not enough: the new members of the population have to be judged on a new criterion too.

7. An empty body is promoted to a confident empty result

REST 200 with {}, or a body with none of the expected fields, is routinely mapped to count:0, [], or a bare {ok:true}. Sometimes that is correct. The problem is that it is indistinguishable from the case where the upstream returned a partial or unexpected shape, and the handler decided the empty version was close enough. A field that is expected and absent is not the same as a field that is present and zero.

8. A null scalar is collapsed to the number zero

For JSON-RPC, a well-formed response can carry result: null. That is a legitimate protocol value meaning "no result" โ€” and it is emphatically not 0. But null flows through a numeric default and out the other side as a confident 0, in a balance, a supply, an allowance. A caller reading "your balance is 0" acts very differently from a caller reading "I could not read your balance", and the difference was erased by a single || 0.

The generalisable rule: an endpoint may only return a value it actually observed. "I asked and the answer was empty" and "I could not ask" are different results, and an API that charges for the second while reporting the first is selling confident emptiness.

What we changed, and how we know it stays fixed

Fixing the eight instances was the easy half. The half that matters is making the ninth impossible to hide. So the guard that watches for this was not extended โ€” its population was made the thing under test. A census now reads the deployed handler bytes, extracts every outbound mechanism it can find, and classifies each one as either covered by a named guard or flagged as unreviewed. Any new mechanism, or any require of a module not on the reviewer's list, becomes a finding. The list is no longer "the mechanisms we remembered"; it is "the mechanisms the code actually contains", and the two are forced back into agreement on every run.

347 / 1,703
deployed handlers with an outbound call, across six mechanism classes โ€” literal fetch (236), shared helper (59), raw net/TLS (12), child process (5), DNS (22), provider library (13). Every class is on the census table and has its own guard.

The honest current state: all eight shapes are fixed, and every guard in the family reports clean. That is a statement about our handlers as deployed today, on this date โ€” not a permanent property. It is exactly the kind of claim that rots, which is why the census re-derives the population on every run rather than trusting the last answer.

The narrower lesson is for anyone shipping a paid endpoint. The distinction your handler has to preserve is not "success versus failure". It is three states, not two: the upstream answered with the data, the upstream answered without it, and you did not get an answer. Most codebases can express the first two and quietly fold the third into whichever one is more convenient. That fold is where a paid API starts charging for results it invented.

The endpoints behind this post are live and public. Every one answers 402 with the same challenge shape, and the first five calls per signed wallet are free โ€” enough to check, on your own traffic, whether an empty answer is real.

See the catalogue โ†’

Method: the population and mechanism counts are read from the deployed handler bytes (1,703 handlers) on 2026-10-13, and the per-mechanism guards were re-run the same day; the census that classifies outbound mechanisms is itself a shipped guard. All readings are first-party โ€” no third-party endpoint was queried to produce this post.