Skip to main content

Handling errors

@zimic/fetch fully types the requests and responses based on your schema. If a response fails with a status code in the 4XX or 5XX ranges, the response.ok property will be false. In this case, response.error will contain a FetchResponseError representing the failure.

Handling response errors

To handle errors, check the response.status or the response.ok properties to determine if the request was successful or not. Alternatively, you can throw the response.error to handle it upper in the call stack.

The onResponse listener can be a good strategy if you want to handle errors transparently. The listener can automatically retry the request without bubbling the error up to the caller. This is useful for recoverable errors, such as expired tokens, network errors, or temporarily unavailable services. You can also use it to log errors.

As an example, consider the following schema:

import { HttpSchema } from '@zimic/http';

interface User {
id: string;
username: string;
}

type Schema = HttpSchema<{
'/users/:userId': {
GET: {
request: {
headers?: { authorization?: string };
};
response: {
200: { body: User };
401: { body: { code: 'UNAUTHORIZED'; message: string } };
403: { body: { code: 'FORBIDDEN'; message: string } };
404: { body: { code: 'NOT_FOUND'; message: string } };
500: { body: { code: 'INTERNAL_SERVER_ERROR'; message: string } };
503: { body: { code: 'SERVICE_UNAVAILABLE'; message: string } };
};
};
};
}>;

A GET request to /users/:userId may be successful with a 200 status code, or it may fail with a 401, 403, 404, 500, or 503. After receiving the response, we can check the status code and handle errors accordingly.

import { createFetch } from '@zimic/fetch';

const fetch = createFetch<Schema>({
baseURL: 'http://localhost:3000',
});

async function fetchUser(userId: string) {
const response = await fetch(`/users/${userId}`, {
method: 'GET',
});

// If the user was not found, return null
if (response.status === 404) {
return null;
}

// If the request failed with other errors, throw them
if (!response.ok) {
throw response.error;
}

// At this point, we know the request was successful
const user = await response.json(); // User
return user;
}
TIP: Throwing unknown errors

Checking the response.ok and response.status properties is a good practice handle errors. A common strategy is to first check status codes that require specific logic, depending on your application, and throwing other errors a global error handler.

if (response.status === 401) {
redirectToLogin();
}

if (response.status === 404) {
return null;
}

if (response.status === 500) {
showMessage('An unexpected error occurred.');
}

if (response.status === 503) {
showMessage('The service is temporarily unavailable.');
}

// Throw any other error
if (!response.ok) {
throw response.error;
}

Logging response errors

response.error.toObject() returns a plain object representation of the error, making it easier to log, inspect, and debug.

import { FetchResponseError } from '@zimic/fetch';

if (error instanceof FetchResponseError) {
console.error(error.toObject());
}

Request and response bodies are not included by default in the result of toObject(). If you want to see them, use includeRequestBody and includeResponseBody. Note that the result will be a Promise that needs to be awaited.

import { FetchResponseError } from '@zimic/fetch';

if (error instanceof FetchResponseError) {
const errorObject = await error.toObject({
includeRequestBody: true,
includeResponseBody: true,
});
console.error(JSON.stringify(errorObject));
}

Logging response errors with pino

If you are using pino, a custom serializer may be useful to automatically call response.error.toObject().

In the following example, we create a logger.errorAsync method to include the request and response bodies, if available, which are serialized to a string using util.inspect() to improve readability. The default logger.error method can still be used to log errors without the bodies.

logger.ts
import { FetchResponseError } from '@zimic/fetch';
import pino, { Logger, LoggerOptions } from 'pino';
import util from 'util';

function serializeBody(body: unknown) {
return util.inspect(body, {
colors: false,
compact: true,
depth: Infinity,
maxArrayLength: Infinity,
maxStringLength: Infinity,
breakLength: Infinity,
sorted: true,
});
}

const syncSerializers = {
err(error: unknown): unknown {
if (error instanceof FetchResponseError) {
// Log response error without bodies
const errorObject = error.toObject({
includeRequestBody: false,
includeResponseBody: false,
});

return pino.stdSerializers.err(errorObject);
}

if (error instanceof Error) {
return pino.stdSerializers.err(error);
}

return error;
},
} satisfies LoggerOptions['serializers'];

const asyncSerializers = {
async err(error: unknown): Promise<unknown> {
if (error instanceof FetchResponseError) {
// Log response error with bodies, if available
const errorObject = await error.toObject({
includeRequestBody: !error.request.bodyUsed,
includeResponseBody: !error.response.bodyUsed,
});

// Serialize bodies to a string for better readability in the logs
for (const resource of [errorObject.request, errorObject.response]) {
if (resource.body !== undefined && resource.body !== null) {
resource.body = serializeBody(resource.body);
}
}

return pino.stdSerializers.err(errorObject);
}

return syncSerializers.err(error);
},
} satisfies LoggerOptions['serializers'];

interface AsyncLogger extends Logger {
errorAsync: (this: AsyncLogger, error: unknown, message?: string) => Promise<void>;
}

// Create logger
const logger = pino({
messageKey: 'message',
errorKey: 'error',
nestedKey: 'data',
formatters: {
level: (label) => ({ level: label }),
},
serializers: syncSerializers,
}) satisfies Logger as AsyncLogger;

// Declare logger.errorAsync method
logger.errorAsync = async function (this: AsyncLogger, error: unknown, message?: string) {
const serializedError = await asyncSerializers.err(error);
this.error(serializedError, message);
};

Using the logger:

import { createFetch } from '@zimic/fetch';
import { logger } from './logger';

const fetch = createFetch<Schema>({
baseURL: 'http://localhost:3000',
});

const response = await fetch(`/users/${userId}`, {
method: 'GET',
});

if (response.ok) {
logger.info('User fetched successfully.');
} else {
// Synchronous, without bodies
logger.error(response.error, `Could not fetch user ${userId}.`);

// Asynchronous, with bodies if available
await logger.errorAsync(response.error, `Could not fetch user ${userId}.`);
}