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. Two things about it will mislead a reader who is not told, and both are stated first.

A refusal is detected by prose, never by a numeric code

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

FieldMeaning
status401 | 403 | 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 five statuses mean

StatusMeaning
401 + reauthThe session is gone — token rejected, expired, or unknown.
403The caller is who they say they are and may not do this.
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_DEAD502yesTorn down; build another.
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_DENIED403noMatched the refusal pattern below.
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

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.