Improve your data fetching with Expected Errors
Learn how to improve both your developer experience and app reliability with Expected Errors.

Not all errors are created equal
At Storyblok, one of the objective was to be able to rely on our application monitoring tool (Sentry) in order to alert us when new issues sneaked in to resolve them quickly, even before customers would start reporting them to us.
With the existing setup, it was hard to find a threshold to trigger alerts as we had a low signal-to-noise ratio due to a large number of re-occuring errors such as the ones below:

When we started to dig deeper into those errors, we realized that we could not fix most of them as they could be valid and expected.
For example, someone opening a link to a space that was deleted or that they no longer have access to.
We also had a few UX bugs where the frontend was more permissive than the backend which we fixed.
Evaluating the status quo
We were using Axios for data fetching, a simplified version of our code looked similar to this:
async function fetchData<TData = unknown>(status: number) { try { const res = await axios.get<TData>( `https://mock.httpstatus.io/${status}` ); return res.data; } catch (error) { Sentry.captureException(error) return null; }}
Here, I am using mock.httpstatus.io
for the sake of the exercise and experiment with different status codes.
With axios defaults, fetching from https://mock.httpstatus.io/403
would return a 403 status code error and enter the catch clause above, logging the exception to Sentry.
There is a way to change this for Axios by using validateStatus
:
async function fetchData<TData>(status: number = 200) { try { const res = await axios.get<TData>(`https://mock.httpstatus.io/${status}`, { validateStatus: (status) => status >= 200 && status < 500, }); return res.data; } catch (error) { Sentry.captureException(error) return null; }}
But there were still a few problems we wanted to solve:
-
All 4xx errors are again created equal and we are blind to some actual errors. This can be partially mitigated by passing a custom
validateStatus
function to each call. -
When we get an expected 4xx error,
response.data
is not of the shape we expect it to be as the call failed. -
We ideally wanted to centralize Sentry error handling for data fetching and not disseminate it in the code.
Expect your errors with a request wrapper
What we came up with is a higher order function wrapping our API calls and handling all the boilerplate centrally:
async function handleRequest<TData>({ apiCall, expectedErrors,}: { apiCall: () => Promise<AxiosResponse<TData>>; expectedErrors?: Map<number, string>;}): Promise<SafeAxiosResponse<TData>> { try { const response = await apiCall(); return { status: "success", data: response.data, }; } catch (error) { if (error instanceof AxiosError) { if (expectedErrors?.has(error.response?.status)) { return { status: "expectedError", error: expectedErrors.get(error.response.status), }; }
Sentry.captureException(error); return { status: "unexpectedError", error: "An unexpected error occurred", }; }
Sentry.captureException(error); return { status: "unexpectedError", error: "An unexpected error occurred", }; }}
Let me walk you through the code step by step:
- First, the function takes an API call parameter which here is configured for Axios but it could be anything native fetch to an OpenAPI client. It also take in input an optional Map which will map the potential errors we are to expect alongside the localized error we want them to return:
apiCall: () => Promise<AxiosResponse<TData>>; expectedErrors?: Map<number, string>;
- If the call succeeds and we have a 2xx response, we return early with the data and a “success” status:
const response = await apiCall(); return { status: "success", data: response.data, };
- if we have an AxiosError which status code can be found in our expectedErrors Map, then we return an “expectedError” status and we do not log the error in Sentry:
if (expectedErrors?.has(error.response?.status)) { return { status: "expectedError", error: expectedErrors.get(error.response.status), }; }
- If we have an AxiosError which status code is not in our expectedErrors Map, then we return an “unexpectedError” and we log the error in Sentry:
Sentry.captureException(error); return { status: "unexpectedError", error: "An unexpected error occurred", };
- Finally if we do not have an AxiosError, we return “unexpectedError” and we also log the error in Sentry (same as above):
Sentry.captureException(error); return { status: "unexpectedError", error: "An unexpected error occurred", };
When we tie it all back together, our previous code to fetch data becomes:
async function fetchData<TData>(status: number = 200) { const response = await handleRequest({ apiCall: () => axios.get<TData>(`https://mock.httpstatus.io/${status}`), expectedErrors: new Map([ [403, "Not authorized"], [404, "Not found"], ]), }); return response;}
And the following call won’t be logged in Sentry:
await fetchData(404) // won't be logged as 404 is an expected error
But this one will:
await fetchData(422) // will be logged as 42 isn't an expected error
And here is the best part, thanks to this high order function, we can now improve our developer experience (DX).
Type Safe data fetching with response statuses
From the handleRequest method above, there is one thing I omitted: SafeAxiosResponse
.
I will show you in this last section how a little bit of TypeScript will get you a long way to improve your developer experience.
Here are the types for SafeAxiosResponse
which is simply a discriminated union:
type ExpectedAxiosError = { status: "expectedError"; error: string;};
type UnexpectedAxiosError = { status: "unexpectedError"; error: string;};
type SuccessAxiosResponse<TData> = { status: "success"; data: TData;};
export type SafeAxiosResponse<Data> = | SuccessAxiosResponse<Data> | ExpectedAxiosError | UnexpectedAxiosError;
This allows the type system to guide us as to what we can access depending on the API call result. For example if we try and fetch the following:
const response = await fetchData<{ message: string }>();
You now have full TypeScript type safety and you cannot use the data returned before you have checked the status of the API call so by default you only have access to the status field:

When you’ve validated that you are on the success case, you can now access the data fields and its property and you can even type them if you know their shape or use an OpenAPI client:

You can (and should) also handle error cases properly, especially for your expected errors with your localized error:

Happy data fetching!