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

A 404 About Your Caller's ID Is Not Your Service Failing

2026-10-27 ยท engineering note

A pay-per-call API answers two questions about every non-answer. Whose fault was it? and should the caller try again? Get the first one wrong and the damage is not a misleading message โ€” it is the wrong remedy applied to the wrong party. In our case the remedy is a delist counter.

This is the story of one handler that answered {ok:false,'CWE not found'} to an id the caller had typed wrong, and what the delivery ledger did with that.

Two doors leave a handler

Almost every service body on minia2a is twenty lines of JavaScript. Errors can leave by exactly two doors, and the difference between them is a message to the ledger about who is responsible.

// door 1 โ€” the caller can fix this
throw new BadRequestError('cwe must be a MITRE CWE number, e.g. 79');

// door 2 โ€” we could not deliver
return { ok: false, error: 'upstream HTTP 503', retryable: true };

Door 1 becomes HTTP 400 with inputError:true. No delivery is recorded, nothing is charged, and on the trial path the allowance is not consumed โ€” a rejected call costs the wallet nothing. Door 2 becomes HTTP 200 with an envelope the caller must parse, and the gateway books a failed delivery against the service. Repeated, that counter is what takes a service off the catalogue.

What the ledger can see

The ledger records a service, an outcome, and the error text. It cannot see intent. If a handler sorts a caller's typo into door 2, the ledger has no way to disagree โ€” it will faithfully count a working service as broken. The only place the truth exists is the handler, which is why the choice of door is not a style question.

The seam

Ours looked like this, in a service that resolves a CWE id against MITRE's public API:

if (r.status === 404) return { ok: false, error: 'CWE not found' };   // before
if (r.status === 404) throw new BadRequestError('CWE not found: ' + cwe);  // after

Ten lines away, in a sibling service that resolves a CVE id against a different public API, the same branch already read throw new BadRequestError('CVE not found'). The two files had drifted apart one fix at a time: someone hardened the CVE path and the CWE path kept the old shape.

The cost was three-fold. A caller asking for an id that does not exist pushed the service toward the delist threshold. Each answer was billed and then refunded, until the refund rail's own anti-abuse cap engaged and the next one was not refunded at all โ€” the caller paid for “not found”. And the service itself was nearly removed from the catalogue for doing exactly what it was asked.

An outcome's cause has to name the party who can fix it. If nobody can fix it by retrying, it is not a failed delivery.

Three shapes, one correct

branchdoor
if (r.status === 404) return {ok:false, error:'X not found'}2 โœ—
if (r.status === 404) throw new BadRequestError('X not found: ' + id)1 โœ“
if (!r.ok && r.status !== 404) return {ok:false, retryable: r.status >= 500 || r.status === 429}2 โœ“

The shape is narrow on purpose. Only 404 about an identifier the caller supplied moves to door 1. A 5xx or a 429 stays in door 2 with retryable:true, because that is our problem and the caller's correct move is to come back later. We re-tested the negative control after the change: a stubbed 429 still returns {ok:false, retryable:true}, a stubbed 503 likewise.

Then we took a census rather than trusting the one file. Twenty-seven handlers had an upstream-404 branch. Nine of them sorted it into door 2 โ€” including two where the file's own comment said not to, and one where only the upstream's 400 branch had been fixed while the 404 beside it had not. All nine throw now. The change is in the service body; no gateway rebuild, no schema change, no new field.

Counting callers, not calls

There is a second lesson in the same incident, and it is about the counter, not the handler. A counter that adds one per failed call measures how often a mistake repeats, not how broken the service is. One agent retrying a typo can ring it; three independent wallets hitting the same wall is a service problem. Our delist gate now requires independent sources โ€” callers that have each proved control of a wallet โ€” before it acts. The threshold is not the point; the axis is. A per-call counter answers a question nobody asked.

The bug that came out of testing the bug

While writing the stub tests for the census we found a different defect in a JSON-path service: it returned success with the value missing.

// before: null check runs BEFORE descending
for (const part of path.split('.')) {
  if (cur == null) return { ok: false, error: 'path not found at: ' + part };
  cur = cur[part];
}
return { ok: true, result: { path, value: cur } };

If the last segment does not exist, the loop finishes normally with cur set to undefined, and the handler reports success. JSON.stringify then drops the key, so the wire carries {"ok":true,"result":{"path":"a.c"}} โ€” a success with no value in it. The caller cannot distinguish that from a bug in its own parser.

if (cur === undefined) throw new BadRequestError('path not found: ' + path);
return { ok: true, result: { path, value: cur } };

The comparison is === undefined, not == null, and that is not pedantry: {"a": null} queried at a is a real answer and must come back as value: null. The test that catches this asserts the shape of the success โ€” is the key there? โ€” not merely that nothing threw.

Rules we are keeping

  1. Attribute the cause to whoever can fix it. A caller's bad identifier is not a service failure, even when the news arrives as a 404 from someone else's server.
  2. Cause and retryable are different axes. “Not your fault” and “come back later” answer different questions; a handler should answer both.
  3. A rejected call must not cost the caller anything โ€” not money, not a trial. That property is testable end-to-end, and we test it after every change to a handler.
  4. Test the last segment. Off-by-one in a loop guard produces successes, not exceptions, and successes are what tests forget to check.
  5. Count sources, not repetitions. Applies to delist gates, abuse counters, and every metric that decides whether to punish someone.

What we did not fix

The refund cap stays. After a handful of refunds for one service in a short window, the rail stops refunding and the caller bears the call. That is deliberate anti-abuse behaviour, and it is exactly why misclassification is expensive: the cap turns a wrong error message into money. Fixing the door does not make the cap safe by itself โ€” it just removes the most common way to reach it.

The distinct-caller gate lowers the false-delist rate; it does not make false delists impossible. And “not found” is still a non-answer โ€” we added a hint pointing at where to check the id, which is more honest than pretending the caller asked a good question.


minia2a serves pay-per-call x402 endpoints in USDC on Base. Five free trial calls per signed wallet, no registration. The delivery rules described here are what the platform applies to itself.

minia2a.uk ยท docs