Errors
The package exports one classifier that gives every failure — from the connection, the pool or the session tier — a single answer. Two things about it will mislead a reader who is not told, and both are stated first.
The key server does not put an authorization-failure code on the wire. Every dispatched
module operation is answered through a string-only error path that never sets a code, so
the client sees code 100 whatever was actually thrown. Measured: an authorization
refusal arrives as code(100), not as the authorization code the engine raised.
Anything that classifies refusals by matching a numeric code will therefore classify every refusal as an unrelated error and produce a table that says the opposite of the truth. The message text is all there is.
The authentication conversation is the exception that proves the rule: it propagates its error unchanged, so a code survives there. That is why the classifier matches strings and not numbers even for the cases where a number happens to be present — matching numerically would work in the authentication path and silently fail everywhere else.
classifyError
import { classifyError } from 'server-ts-agent';
const c = classifyError(err);
// { status, code, detail, retryable, reauth?, reason?, scope? }
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 | 401 | 403 | 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 five statuses mean
| Status | Meaning |
|---|---|
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. |
| 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_DEAD | 502 | yes | Torn down; build another. |
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 | Matched the refusal pattern below. |
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.
Refusals the key server raises with their own wording fall through this pattern to the
OP_FAILED default. Two you will meet in practice:
Cannot modify own account— the self-principal guard on principal updates.Cyclic Membership is not permitted— the group-cycle guard.
Both are legitimate refusals of a request the caller could have fixed, and both classify as 500 unless you match the sentence yourself. Surfaces built on this agent classify them locally for exactly that reason; if you are writing one, do the same.
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.
One known gap, recorded rather than documented as a contract
A key server that resets a pooled connection currently surfaces as an ordinary
operation failure carrying read ECONNRESET, classified 500. Measured on a live key
server: after a principal was disabled and re-enabled, the next operation on the existing
session answered 500 OP_FAILED / read ECONNRESET, and the operation after that answered
401 TOKEN_REJECTED.
A peer resetting a pooled socket is not a contract violation, and a caller reading that
500 is told the wrong thing. This is a known open defect in the taxonomy, not intended
behavior. Do not build classification on it and do not treat the read ECONNRESET
string as a contract; it is recorded here so that meeting it is not mistaken for a bug in
your own integration.