Skip to main content

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.

A refusal carries the code that was thrown — and the prose still matters

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​

FieldMeaning
status400 | 401 | 403 | 404 | 500 | 502 | 503
codeStable machine-readable identifier. Never a message — match on this.
detailHuman-facing text: the underlying message, not a rewrite of it.
retryableWould retrying this same request plausibly succeed?
reauthtrue 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​

StatusMeaning
400The key server refused an argument as invalid.
401 + reauthThe session is gone — token rejected, expired, or unknown.
403The caller is who they say they are and may not do this.
404The named configuration parameter does not exist.
500An operation failed, or the agent has a bug.
502The key server could not be reached, or would not talk to us.
503Capacity — 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.

CodeStatusRetryableNotes
POOL_ACQUIRE_TIMEOUT503yesscope: 'session' — this session is using every connection it may.
POOL_AGGREGATE_LIMIT503yesscope: 'process' — the process-wide ceiling.
POOL_CLOSED503yesscope: 'process'.
POOL_CONNECT_FAILED502yesThe factory could not produce a connection.
POOL_VALIDATE_TIMEOUT502yesA reused entry failed its validation ping.
POOL_RELEASED_BUSY500noContract violation: an entry came back still busy.
CONNECTION_BUSY500noTwo operations on one connection — see the connection model.
CONNECTION_NOT_READY500noUsed before open() resolved.
CONNECTION_ALREADY_OPENED500noopen() called on an open connection.
CONNECTION_DEAD502yesTorn down; build another.
CONNECTION_TIMEOUT, CONNECTION_CLOSED, CONNECTION_DESTROYED502yesThe key server did not answer, or the socket went away.
ECONNRESET, EPIPE, ECONNABORTED502yesA raw socket error with no code of ours around it — see below.
SESSION_EXPIRED401noreason: 'idle'.
SESSION_NOT_FOUND401noreason: 'unknown'.
SESSION_LIMIT503yesscope: 'process'.
SESSION_MANAGER_CLOSED503yesscope: 'process'.
TOKEN_REJECTED401noreason: 'token' — the key server refused the credential.
CREDENTIALS_REJECTED401noreason: 'credentials' — the password was wrong.
PRINCIPAL_DISABLED403noThe account exists and is switched off.
ACCESS_DENIED403noThe key server's authorization code, or the refusal pattern below.
PARAMETER_NOT_FOUND404noThe named parameter does not exist. Also thrown typed as ParameterNotFoundError; test with isParameterNotFound().
INVALID_ARGUMENT400noThe key server refused the argument; detail is its sentence.
OP_FAILED500noThe 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.

It does not cover every refusal, and it cannot

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.