Errors
The package exports one classifier that gives every failure — from the connection, the pool or the session tier — a single answer. One thing about it will mislead a reader who is not told, and it is stated first.
The key server answers a refused module operation with the error code that was thrown
(ADR-0097): an authorization refusal arrives as ERR_AUTHORIZATION_FAILED (768), an
absent parameter as ERR_PARAMETER_NOT_FOUND (1284), a refused argument as
ERR_INVALID_ARGUMENT (1283). The classifier maps those codes first. Older key servers
answered every dispatched refusal with code 100, whatever was thrown, so the
classifier also keeps matching the refusal text — and many refusals are raised with a
general code whose sentence is the only specific statement of why. Match on the
classifier's code, not on raw numbers.
classifyError
import { classifyError } from 'server-ts-agent';
const c = classifyError(err);
// { status, code, detail, retryable, reauth?, reason?, scope? }
const a = classifyError(err, { phase: 'authentication' });
Pass { phase: 'authentication' } from a call site that knows it was signing a user
in. In that phase an unknown user and a wrong password are answered identically, and
detail is replaced with a fixed sentence, so the classification cannot tell a caller
which user names exist. Everywhere else detail is the underlying message.
classifyError is total: every input gets an answer, and an unrecognized one is a 500
rather than a throw. A classifier that can itself fail is a classifier that turns a handled
error into an unhandled one.
The shape
| Field | Meaning |
|---|---|
status | 400 | 401 | 403 | 404 | 500 | 502 | 503 |
code | Stable machine-readable identifier. Never a message — match on this. |
detail | Human-facing text: the underlying message, not a rewrite of it. |
retryable | Would retrying this same request plausibly succeed? |
reauth | true on the 401 family only. |
reason | 'token' | 'credentials' | 'idle' | 'unknown'. Only with reauth. |
scope | 'session' | 'process'. Only on capacity failures. |
What the seven statuses mean
| Status | Meaning |
|---|---|
| 400 | The key server refused an argument as invalid. |
401 + reauth | The session is gone — token rejected, expired, or unknown. |
| 403 | The caller is who they say they are and may not do this. |
| 404 | The named configuration parameter does not exist. |
| 500 | An operation failed, or the agent has a bug. |
| 502 | The key server could not be reached, or would not talk to us. |
| 503 | Capacity — this session's, or the whole process's. |
502 and 503 are separated on purpose: a caller cannot usefully retry a 500 but can
usefully retry a 503, and collapsing capacity into 500 throws that away. Likewise 503
means "we are full" and 502 means "the key server did not answer".
reason is not decoration
The four values answer different questions and a login screen renders them differently:
'credentials'— the key server rejected the password. Collect a new one.'token'— a stored session credential was refused. A silent re-login may work.'idle'— the agent's own session expired. Not a statement about the credential.'unknown'— the session id is not one the agent knows.
Do not collapse 'credentials' into 'token'. Doing so tells a login route to retry
silently with a token that is perfectly valid, and hides the one fact the operator needs.
An expired session is answered 401, not 503
A reaped session is a lifecycle failure rather than an authentication failure, and it is
still answered 401. The reason is what the caller does next: 503 says "try again", and
retrying with a dead session id fails forever, whereas 401 names the action that actually
works. reason: 'idle' is what keeps it from reading as "your token was rejected".
The exported codes
Pool failures arrive as PoolError, session failures as SessionError; both carry a
code property and prefix their message with CODE: . Connection failures are plain
Errors using the same CODE: message convention, so one extractor covers all three
tiers.
| Code | Status | Retryable | Notes |
|---|---|---|---|
POOL_ACQUIRE_TIMEOUT | 503 | yes | scope: 'session' — this session is using every connection it may. |
POOL_AGGREGATE_LIMIT | 503 | yes | scope: 'process' — the process-wide ceiling. |
POOL_CLOSED | 503 | yes | scope: 'process'. |
POOL_CONNECT_FAILED | 502 | yes | The factory could not produce a connection. |
POOL_VALIDATE_TIMEOUT | 502 | yes | A reused entry failed its validation ping. |
POOL_RELEASED_BUSY | 500 | no | Contract violation: an entry came back still busy. |
CONNECTION_BUSY | 500 | no | Two operations on one connection — see the connection model. |
CONNECTION_NOT_READY | 500 | no | Used before open() resolved. |
CONNECTION_ALREADY_OPENED | 500 | no | open() called on an open connection. |
CONNECTION_DEAD | 502 | yes | Torn down; build another. |
CONNECTION_TIMEOUT, CONNECTION_CLOSED, CONNECTION_DESTROYED | 502 | yes | The key server did not answer, or the socket went away. |
ECONNRESET, EPIPE, ECONNABORTED | 502 | yes | A raw socket error with no code of ours around it — see below. |
SESSION_EXPIRED | 401 | no | reason: 'idle'. |
SESSION_NOT_FOUND | 401 | no | reason: 'unknown'. |
SESSION_LIMIT | 503 | yes | scope: 'process'. |
SESSION_MANAGER_CLOSED | 503 | yes | scope: 'process'. |
TOKEN_REJECTED | 401 | no | reason: 'token' — the key server refused the credential. |
CREDENTIALS_REJECTED | 401 | no | reason: 'credentials' — the password was wrong. |
PRINCIPAL_DISABLED | 403 | no | The account exists and is switched off. |
ACCESS_DENIED | 403 | no | The key server's authorization code, or the refusal pattern below. |
PARAMETER_NOT_FOUND | 404 | no | The named parameter does not exist. Also thrown typed as ParameterNotFoundError; test with isParameterNotFound(). |
INVALID_ARGUMENT | 400 | no | The key server refused the argument; detail is its sentence. |
OP_FAILED | 500 | no | The default. An operation failed, or we have a bug. |
CONNECTION_BUSY and CONNECTION_NOT_READY are 500, not 503, and the distinction is
deliberate. They look like contention and are not: they are contract violations on the
caller's side, and retrying cannot help.
A disabled principal is 403 and not retryable, which stretches the 403 gloss above. The alternative is worse: 401 plus re-authentication tells a caller "collect a credential and try again", and for a disabled account that is futile — no password and no token will work until an administrator re-enables it. 403 says "stop, and it is not about your credential", which is the only action that helps.
The refusal pattern
DENY_PATTERN is exported, so you can see exactly what is treated as a denial:
/ERR_OPERATION_FAILED|does not have privilege|Authorization Failed|has \[?no\]? entry|Add ACL Entry Failed/i
Its oddities — the optional-bracket tolerance, the case-insensitive flag — are load-bearing against real key server messages. It is a regression oracle, kept byte for byte.
A refusal the key server raises with its own wording and a general code falls through
this pattern to the OP_FAILED default. The one you will meet in practice is
Cyclic Membership is not permitted — the group-cycle guard. It is a legitimate refusal
of a request the caller could have fixed, and it classifies as 500 unless you match the
sentence yourself. Surfaces built on this agent classify it locally for exactly that
reason; if you are writing one, do the same.
Cannot modify own account — the self-principal guard on principal updates — used to
fall through the same way. It is raised with the authorization code, so it now classifies
as 403 ACCESS_DENIED.
Connectors must mark token failures
If you supply the connection factory yourself, wrap token authentication failures in
reauthError():
import { reauthError } from 'server-ts-agent';
Without it the pool wraps the failure in POOL_CONNECT_FAILED and an expired token
becomes indistinguishable from a key server that is down — 401 versus 502, which is the
difference between "log in again" and "wait". This is the one thing a custom connector
has to get right.
The classifier walks the whole cause chain looking for the most specific statement of
why, rather than classifying the outermost error, precisely because errors nest this way.
A reset connection is a 502
A key server that resets a pooled connection surfaces as a raw socket error —
read ECONNRESET, write EPIPE — with no code of the agent's around it. The classifier
answers it 502, retryable, with the errno as the code. It does so only after every
authentication and denial check, so a session whose token has died still answers 401
even when the socket carrying that news was itself reset.
This page used to record the reset as a known defect classified 500 OP_FAILED; that
classification was corrected in the agent, and a caller should no longer meet it.