Skip to content

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:

typescript
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 a 4xx/5xx status. response and status are set; code defaults to 'HTTP_RESPONSE_ERROR'.
  • NetworkError — the request never produced a response (DNS failure, connection reset, abort). response is undefined and code carries 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:

typescript
const payload = JSON.parse(await response.text()); // an error emitted by toJSON()

isHapicError(payload);        // true
isHttpResponseError(payload); // true

isHapicError ​

The broadest guard — matches any error thrown by any hapic package (request, auth, or connection), and nothing else:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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').

CausecodeisClientErrorDueTimeoutisClientErrorDueNetworkIssue
timeout elapsed'ETIMEDOUT'truetrue
caller signal aborted with a TimeoutError (AbortSignal.timeout(...))'ETIMEDOUT'truetrue
caller signal aborted (any other reason)'ECONNABORTED'falsefalse

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:

typescript
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:

typescript
client.on('responseError', async (error) => {
    if (isClientErrorWithStatusCode(error, 401)) {
        return { ...error.request, headers: await freshAuthHeaders() };
    }
    throw error;
});

See Hooks for the full recovery model.