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.
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.
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.
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.
| 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.
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.
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.
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