Skip to content

Error Handling & Retries

Zlient is designed to facilitate robust error handling by providing typed errors and automatic retry strategies.

ApiError

Any request failure (network, validation, or non-success status code) throws an ApiError.

typescript
import { ApiError } from 'zlient';

try {
  await getUser({ ... });
} catch (err) {
  if (err instanceof ApiError) {
    if (err.isValidationError()) {
      // Validation failed (works with Zod, Valibot, ArkType, etc.)
      console.log(err.validationIssues);
      // [{ message: 'Expected string, received number', path: ['id'] }]
    } else if (err.isClientError()) {
      // 4xx error (e.g. 404, 400)
    } else if (err.isServerError()) {
      // 5xx error
    }

    // Original status code
    console.log(err.status); // e.g. 404
    console.log(err.method); // e.g. "GET"
    console.log(err.url); // e.g. "https://api.example.com/users/123"
    console.log(err.details); // Parsed error response body when available
  }
}

For HTTP status errors, the message includes the request method, URL, status code, status text, and a server-provided message, error, title, or detail field when present. The parsed response body is available on err.details.

Validation Errors

If your response schema doesn't match what the server returned, Zlient throws immediately. This "Fail Fast" approach prevents corrupted data from flowing into your application logic.

Standard Schema Issues Validation issues follow the Standard Schema format, regardless of which validation library you use:

typescript
interface Issue {
  message: string;
  path?: (string | number | symbol)[];
}

Retry Strategy

Zlient automatically retries requests based on the configured strategy using exponential backoff.

Configuration

typescript
const client = new HttpClient({
  baseUrls: { default: 'https://api.example.com' },
  retry: {
    maxAttempts: 3, // Total number of retry attempts (not counting the initial request)
    baseDelayMs: 1000, // Initial delay in milliseconds for exponential backoff
    retryMethods: ['GET', 'POST', 'PUT'], // HTTP methods eligible for retry
    retryStatusCodes: [500, 502, 503, 504], // HTTP status codes eligible for retry
    respectRetryAfter: true, // Honor 'Retry-After' header if present
    shouldRetry: (ctx) => {
      // Optional: custom retry logic. Called before each retry attempt.
      // ctx: { url, method, status, attempt, response? }
      return ctx.status !== 400; // e.g. never retry a 400 Bad Request
    },
  },
});

Exponential Backoff

Delays between retries follow this formula: baseDelayMs * 2^(attempt - 1)

  • 1st retry: baseDelayMs * 2^0 = 1000ms
  • 2nd retry: baseDelayMs * 2^1 = 2000ms
  • 3rd retry: baseDelayMs * 2^2 = 4000ms

Retry-After Support

When respectRetryAfter is true, Zlient respects the Retry-After header from the server:

typescript
retry: {
  maxAttempts: 3,
  baseDelayMs: 1000,
  retryStatusCodes: [429, 503],
  retryMethods: ['GET'],
  respectRetryAfter: true,  // Will use header value if present
}

Per-Request Control

Skip retry for a specific request:

typescript
const { data } = await client.get('/critical-check', { skipRetry: true });

Skip retry for a specific endpoint:

typescript
const risky = client.createEndpoint({
  method: 'POST',
  path: '/payment',
  response: z.object({ success: z.boolean() }),
  advanced: { skipRetry: true },
});

Handling 401s & Token Refresh

onUnauthenticated lets the client recover from an expired token without the caller having to retry manually. It's called once per request when a 401 response is received; returning true retries the request exactly once, and false (or a thrown error) surfaces the 401 as a normal ApiError.

typescript
const client = new HttpClient({
  baseUrls: { default: 'https://api.example.com' },
  auth: new BearerTokenAuth(() => getCurrentAccessToken()),
  onUnauthenticated: async () => {
    const refreshed = await refreshAccessToken(); // updates whatever getCurrentAccessToken() reads
    return refreshed;
  },
});

Before the retry, auth is re-applied so your AuthProvider picks up the refreshed token — unless the original request was sent with skipAuth: true, in which case onUnauthenticated still runs and can still trigger the retry, but auth is never (re-)applied.

A request that's been opted out with skipAuth: true never triggers onUnauthenticated.

Released under the MIT License.