Errors
A phrase name and never a sentence — and why that is what makes a real error type possible.
Every refusal on this api is the same shape:
{
"message": "errors.oauthInsufficientScope",
"scope": "lists:write"
} message is a phrase name. Not a sentence, and not English — one
acyka screen can be read in five languages at once, and the server does not get to choose which.
Extra fields are merged in beside it wherever a refusal owes a reason: the scope that was
missing, how long a ban has left.
That is usually described as a limitation. For a client library it is the opposite: a stable, enumerable set of names is what makes a real type per refusal possible, instead of matching on prose that changes the day somebody rewrites a sentence.
import { Forbidden, NotFound, RateLimited } from '@acyka/api';
try {
await theirs.library.listMyList();
} catch (err) {
if (err instanceof Forbidden) {
// refreshing will not help: the token does not carry it
ask(err.scope);
} else if (err instanceof RateLimited) {
console.log('retry in', err.retryAfter, 'seconds');
} else if (err instanceof NotFound) {
return null;
}
throw err;
} 401 and 403 are not the same problem #
A 401 means the credential is stale — refresh and try again, which every library does once by itself. A 403 means the token is good and does not carry what this endpoint wants: refreshing produces the same token with the same scopes and earns the same refusal, for ever. A client that retries a 403 is a client in a loop.
The names #
| status | name | what it means |
|---|---|---|
400 | errors.badData | the request was refused before anything looked at it — a status that is not one of the six, a title that is empty |
400 | post.tooLong | a post past the one length the site allows |
400 | post.badMarkup | a wall of punctuation with no words in it |
401 | errors.unauthorized | no token, an expired one, or one whose application was switched off. Refresh, then retry once. |
403 | errors.oauthInsufficientScope | the token is good and does not carry this. Refreshing will not help; ask for the scope. `scope` names it. |
403 | errors.oauthUserRequired | an application acting for itself asked for something about a person |
403 | errors.suspended | the account the token acts for is suspended |
403 | errors.noAccess | a shelf that is readable and not writable by this person |
404 | errors.notFound | there is nothing there, or it is not yours to read |
404 | errors.unknownTitle | no such title — or one marked 18+ and an account that has not opened the switch |
404 | errors.profileNotFound | no account answers to that name |
429 | common.tooOften | the minute is spent. `Retry-After` says how long. |
The list grows. A name your build has never seen is not an error in itself — read err.code, show your own words for the ones you know, and the name itself for the
ones you do not.
// your own words, keyed by the name
const SAYS: Record<string, string> = {
'errors.oauthInsufficientScope': 'This app has not been allowed that yet.',
'errors.suspended': 'That account is suspended.'
};
const shown = SAYS[err.code] ?? 'Something went wrong.'; An answer that is not JSON #
A proxy's own 502 is HTML. Every library here turns that into the same ServerError a JSON 502 would have produced, because a parse error at that moment
tells a caller nothing about what happened — and being told “the api is having a bad minute” is
what they need.