Error Handling
hapic treats unsuccessful requests as errors, not values. Any response with a status in 400–599, and any network/dispatch failure, rejects with a ClientError. This keeps your happy path clean — destructure data and move on — while failures surface loudly.
The ClientError family
ClientError is built on @ebec/core and carries the full context of what went wrong. It is the umbrella type; every thrown error is a ClientError or one of its subclasses:
import { ClientError } from 'hapic';
class ClientError<T = any> extends BaseError { // BaseError comes from @ebec/core
request: RequestOptions; // the options that were dispatched
response?: Response<T>; // present for HTTP errors, absent for network errors
status?: number; // response.status (alias: statusCode)
statusCode?: number;
statusText?: string; // response.statusText (alias: statusMessage)
statusMessage?: string;
code: string; // a machine-readable code, always set (e.g. 'ECONNRESET')
cause?: unknown; // the underlying error, when one exists
}Two subclasses narrow the failure mode:
HttpResponseError— the server answered with a4xx/5xxstatus.responseandstatusare set;codedefaults to'HTTP_RESPONSE_ERROR'.NetworkError— the request never produced a response (DNS failure, connection reset, abort).responseisundefinedandcodecarries the reason (e.g.'ECONNRESET','ECONNABORTED','ETIMEDOUT').
BaseError (@ebec/core)
└─ HapicError ← any hapic error; isHapicError()
├─ ClientError ← request lifecycle; isClientError()
│ ├─ NetworkError ← no response; isNetworkError()
│ └─ HttpResponseError ← 4xx/5xx response; isHttpResponseError()
├─ AuthorizationHeaderError
└─ ConnectionStringParseError ← malformed connection string; isConnectionStringParseError()ConnectionStringParseError lives in the base hapic package — thrown by its shared splitConnectionString helper and re-exported by the service clients (@hapic/harbor, @hapic/vault) that accept a connection string, so a single isConnectionStringParseError recognises it everywhere.
Every error a @hapic/* package throws descends from HapicError, so isHapicError(e) answers "did this come from hapic?" — even for config/auth errors that aren't request failures.
Prefer the guards over instanceof
If a consumer ends up with two bundled copies of hapic, a plain error instanceof ClientError can be false for an error thrown by the other copy. The exported guards instead consult a cross-realm marker chain: every error records a marker for itself and for each of its ancestors, so a single instance is matched by every guard in its lineage — an HttpResponseError answers true to both isHttpResponseError and isClientError.
The guards match the chain loosely: JSON.stringify drops symbols, so error.toJSON() emits the marker chain as plain strings. An error serialised on a server and parsed back on the client is therefore still recognised by every guard in its lineage:
const payload = JSON.parse(await response.text()); // an error emitted by toJSON()
isHapicError(payload); // true
isHttpResponseError(payload); // trueisHapicError
The broadest guard — matches any error thrown by any hapic package (request, auth, or connection), and nothing else:
import { isHapicError } from 'hapic';
try {
await client.get('users/1');
} catch (error) {
if (isHapicError(error)) {
// came from hapic — safe to read error.code / error.message
} else {
throw error; // some other library's error — rethrow
}
}isClientError
Narrower than isHapicError — matches errors from the HTTP request lifecycle (network or HTTP response), but not config/auth errors:
import { isClientError } from 'hapic';
try {
await client.get('users/1');
} catch (error) {
if (isClientError(error)) {
console.error(error.status, error.message);
} else {
throw error; // not ours — rethrow
}
}isHttpResponseError / isNetworkError
Branch on the failure mode directly:
import { isHttpResponseError, isNetworkError } from 'hapic';
if (isHttpResponseError(error)) {
console.error(`server said ${error.status}`);
}
if (isNetworkError(error)) {
// never reached the server — safe to retry with backoff
}isClientErrorWithStatusCode
Match a single status or any of several:
import { isClientErrorWithStatusCode } from 'hapic';
if (isClientErrorWithStatusCode(error, 404)) { /* not found */ }
if (isClientErrorWithStatusCode(error, [401, 403])) { /* unauthorized */ }It returns false for network errors (there's no response to inspect).
isClientErrorDueNetworkIssue
True when the failure never produced a response and was not an explicit abort — there's a code but no response:
import { isClientErrorDueNetworkIssue } from 'hapic';
if (isClientErrorDueNetworkIssue(error)) {
// connection reset, DNS failure, timeout, … — safe to retry with backoff
}isClientErrorDueTimeout
True when the request was aborted by its timeout, or by a caller signal whose reason is a TimeoutError (e.g. AbortSignal.timeout(...)). Any other abort through the caller's own signal keeps the code 'ECONNABORTED' and answers false:
import { isClientErrorDueTimeout } from 'hapic';
try {
await client.get('slow', { timeout: 5_000 });
} catch (error) {
if (isClientErrorDueTimeout(error)) {
// no response within 5 seconds
}
}Timeouts
A timeout (milliseconds, on the client or per request) arms a fresh timer for every dispatch, a request retried by an error hook included. A fractional value is rounded up, and a value above 2147483647 (the timer limit, about 24.8 days) is capped to it. It is combined with a caller signal, so whichever fires first aborts the request. A signal that is already aborted rejects before anything is dispatched. A per-request timeout or signal of undefined keeps the client default.
The timeout bounds the whole exchange, reading the response body included. For responseType: 'stream' the body is not read by hapic, so the timer keeps running while you consume the stream: a stream read past the timeout fails.
The code is decided first by which signal fired. The timeout hapic armed answers 'ETIMEDOUT'. The caller's signal answers 'ETIMEDOUT' when its reason is an error named TimeoutError (the reason of the caller's own AbortSignal.timeout(...)), and 'ECONNABORTED' for any other reason: a string (wrapped into an AbortError whose cause is the string) or an Error of any other name (kept as cause). This relies on the transport rejecting with the signal's reason, as fetch does; a custom transport that rejects with an error of its own is classified by that error's name (TimeoutError → 'ETIMEDOUT', AbortError → 'ECONNABORTED').
| Cause | code | isClientErrorDueTimeout | isClientErrorDueNetworkIssue |
|---|---|---|---|
timeout elapsed | 'ETIMEDOUT' | true | true |
caller signal aborted with a TimeoutError (AbortSignal.timeout(...)) | 'ETIMEDOUT' | true | true |
caller signal aborted (any other reason) | 'ECONNABORTED' | false | false |
Error codes
Every ClientError exposes a code. For HTTP errors it derives from the class name ('HTTP_RESPONSE_ERROR'); for the non-HTTP failure modes the ErrorCode enum names them:
import { ErrorCode } from 'hapic';
ErrorCode.CONNECTION_ABORTED; // 'ECONNABORTED'
ErrorCode.CONNECTION_CLOSED; // 'ECONNRESET'
ErrorCode.CONNECTION_TIMEOUT; // 'ETIMEDOUT'
ErrorCode.AUTHORIZATION_HEADER_INVALID; // 'auth_header_invalid'
ErrorCode.AUTHORIZATION_HEADER_PARSE; // 'auth_header_parse'
ErrorCode.AUTHORIZATION_HEADER_TYPE_PARSE; // 'auth_header_type_parse'Recovering instead of throwing
Catching at the call site is the simplest path. But if you want to handle a failure class centrally — retry on 401, fall back to cache on a network blip — do it in an error hook, which can return a Response or retry RequestOptions so the error never reaches the caller:
client.on('responseError', async (error) => {
if (isClientErrorWithStatusCode(error, 401)) {
return { ...error.request, headers: await freshAuthHeaders() };
}
throw error;
});See Hooks for the full recovery model.