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.
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
| branch | door |
|---|---|
| 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
- 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.
- Cause and retryable are different axes. “Not your fault” and “come back later” answer different questions; a handler should answer both.
- 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.
- Test the last segment. Off-by-one in a loop guard produces successes, not exceptions, and successes are what tests forget to check.
- 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.
Originally published at minia2a.uk.
Top comments (0)