http-fetch-patterns

2021-10-06

 | 

~10 min read

 | 

1841 words

When it comes to making fetch requests on the web, there are a lot of options. You have XHR, fetch, axios, and dozens of others too I’m sure (though these are the ones with which I’m most familiar).

A fact of the web is also variability. I was working recently on creating a pattern that reduces some of that variability and I’m pretty pleased with how it came out. Not only that, but because it creates an abstraction on top of the technology, it’s fairly trivial to swap out the method by which you’re making requests (e.g., replace Axios for native fetch). The biggest differences are around types and whether you have access to some of the tools Axios provides or not.

At a high level, we want our requests to know a few things:

  1. How to discriminate between responses
  2. Whether or not they should retry a message in case of a time out
  3. How to modify headers (e.g., by providing an access token)

Once we get these tools in place, I’ll show how to actually use this approach in a basic way, though it can also be incorporated easily into a Thunk pattern or XState.

Building Our Solution

Let’s now step through a few of the preliminary steps that would be involved in creating the solution.

Each piece on its own might not make sense, but the hope is that by the end, you’ll see them all come together.

Discriminating Responses

This is probably the part that took me the longest to wrap my head around (not sure I’m even fully there). It’s important though as it will underpin everything else we do.

My colleague has been helping me see the benefits of discriminated unions for months now, but this seems like a fantastic use case. It was also this inspiration between this post on type guards and const assertions.1

To begin, we’ll need to have a discriminated union of types that we expect from our requests:

types
// src/fetch/fetchRequest.ts
export enum HttpStatus {
  Ok = "Ok",
  Timeout = "Timeout",
  BadStatus = "Bad Status",
  BadBody = "Bad Body",
  Unknown = "Unknown",
}

interface HttpOk<T> {
  status: HttpStatus.Ok
  data: T
}

export type HttpResponse<T> =
  | HttpOk<T>
  | { status: HttpStatus.BadBody }
  | { status: HttpStatus.BadStatus }
  | { status: HttpStatus.Timeout }
  | { status: HttpStatus.Unknown }

export type FetchStatus<T> =
  | (HttpResponse<T> & { isPending?: false })
  | { isPending: true }
// alternative would be to update each entry of HttpResponse with an {isPending: false}, which is ugly

Here, we have one success track and three different types of errors.

Now, we can begin pulling together a generic request function:

request
// src/fetch/fetchRequest.ts
async function request<T>(
  url: RequestInfo,
  options: Partial<RequestInit>,
): Promise<HttpResponse<T>> {
  try {
    const res = await fetch(url, options)
    return { ok: true, data: res } as const
  } catch (error) {
    const { name, response } = error
    if (name === "AbortError")
      return { ok: false, error: HttpStatus.Timeout } as const

    if (response && (response.status < 200 || response.status >= 300))
      return { ok: false, error: HttpStatus.BadStatus } as const

    return { ok: false, error: HttpStatus.Unknown } as const
  }
}

Because of the discriminated union and the return X as const, request has a very clean return signature that will make consuming the function easy and clear.

Retry Logic

Update: I’ve since changed my mind on building in automatic retries at this level. The issue is that not all requests can be trusted to be idempotent. To avoid unexpected side-effects, defer retry handlers to calls where it makes sense. GET requests are good candidates.

If a request fails due to a timeout, we want to retry it. How many times? We’ll default to one, but the consumer should be able to set that.

We’ll use an AbortController to cancel the request if it exceeds a predefined timeout duration. In the event that we still have retries, however, we want to automatically retry. Per MDN

When a promise rejects due to abort from the AbortController, the error will be of type DOMException with a name of AbortError.

In the case of all other failure events, we will not retry.

replayTimeout
// src/fetch/fetchRequest.ts
export async function replayTimeout<T>(
  func: () => Promise<HttpResponse<T>>,
  remainingRetries = 1,
) {
  do {
    try {
      return await func()
    } catch (error) {
      if (error.name !== "AbortError" || remainingRetries <= 0) {
        throw error
      }
      remainingRetries--
    }
  } while (true)
}

A do-while wasn’t the first place I wanted to go, but it has a few features that are useful here. It’s always run at least once, and we can run it forever - or at least as long as we want. This is potentially problematic, but given the structure of the code, it is not possible to hit an infinite loop. The real benefit though was that this way the compiler knows that we always return something - either the Response<T> or an Error.

We will incorporate that into the request method like so:

request
// src/fetch/fetchrequest.ts
async function request<T>(
  url: RequestInfo,
  options: Partial<RequestInit>,
  retries?: number
): Promise<HttpResponse<T>> {
+   const controller = new AbortController();
+   const { signal } = controller;
+   const timeout = setTimeout(() => controller.abort(), TIMEOUT_THRESHOLD);
  try {
-     const res = await fetch(url, options)
+     const res = await replayTimeout(
+       () => fetch(url, { ...options, signal }),
+       retries
+     );
    return { ok: true, data: res } as const;
  } catch (error) {
    const { name, response } = error;
    if (name === "AbortError")
      return { ok: false, error: HttpStatus.Timeout } as const;

    if (response && (response.status < 200 || response.status >= 300))
      return { ok: false, error: HttpStatus.BadStatus } as const;

    return { ok: false, error: HttpStatus.Unknown } as const;
+   } finally {
+     clearTimeout(timeout);
  }
}

Modifying Headers

Another feature of our fetch pattern will be the automatic setting of default headers. We don’t want every fetch request made in our application to have to re-implement this logic.

A common use case is for security headers like an Authorization header. That’s what we’ll model here.

useAccessToken
// src/fetch/fetchRequest.ts
export const useAccessToken = (
  options: RequestInit,
  storage: Storage = sessionStorage,
) => {
  const accessToken = storage.getItem("AccessToken")
  return {
    ...options,
    headers: {
      ...options.headers,
      ...(accessToken && { Authorization: `Bearer ${accessToken}` }),
    },
  }
}

This pure function knows how to do one thing: retrieve a token from storage and set an Authorization header. This makes it quite testable too!

To use it, we can update our request method with one line change

I like the idea of having separate small functions for modifying each part of the options object, though as the number of these functions increases, we’ll need to come up with a strategy for composing them. (This is actually an area where Axios really shines with its interceptors.)

request
// src/fetch/fetchRequest.ts
async function request<T>(...){
  ...
  try {
    const res = await replayTimeout(
-       () => fetch(url, { ...options, signal }),
+       () => fetch(url, { ...useAccessToken(options), signal }),
      retries
    );
    return { ok: true, data: res } as const;
  } catch (error) {
    ...
  } finally {
    clearTimeout(timeout);
  }
}

Putting The Pieces Together

Our final request method would look like:

request
async function request<T>(
  url: RequestInfo,
  options: Partial<RequestInit>,
  retries?: number,
): Promise<HttpResponse<T>> {
  const controller = new AbortController()
  const { signal } = controller
  const timeout = setTimeout(() => controller.abort(), TIMEOUT_THRESHOLD)
  try {
    const res = await replayTimeout(
      () => fetch(url, { ...useAccessToken(options), signal }),
      retries,
    )
    return { ok: true, data: res } as const
  } catch (error) {
    const { name, response } = error
    if (name === "AbortError")
      return { ok: false, error: HttpStatus.Timeout } as const

    if (response && (response.status < 200 || response.status >= 300))
      return { ok: false, error: HttpStatus.BadStatus } as const

    return { ok: false, error: HttpStatus.Unknown } as const
  } finally {
    clearTimeout(timeout)
  }
}

It might be useful to add some helper functions, particularly if you want to handle different types of requests differently. For example, we want to make sure that when we make a GET request, we don’t try to send along a body:

helpers
// src/fetch/fetchRequest.ts
export async function get<T = unknown>(
  url: RequestInfo,
  options?: Omit<RequestInit, "body">,
  retries?: number,
) {
  return request<T>(url, { ...options, method: "get" }, retries)
}

export async function post<T = unknown>(
  url: RequestInfo,
  options?: RequestInit,
  retries?: number,
) {
  return request<T>(url, { ...options, method: "post" }, retries)
}

While the body is a relatively trivial example, it’s demonstrative of how we can use Typescript to help our users along the happy path!

Using Our Solution

Now that we have the building blocks in place, let’s use it!

import * as React, { useEffect } from "react";
import { get } from "./fetch/fetchRequest";

export default function App() {
  useEffect(() => {
    get<Response>("https://jsonplaceholder.typicode.com/posts")
      .then((res) => {
        if (res.ok) {
          // we can now handle our response however we'd like as we know its shape.
          return res.data.json();
        } else {
          // handle error types
          // The compiler knows that the only responses here are errors
          console.log(res);
        }
      })
      .then((res) => console.log({ res }))
      .catch(e => {/* handle uncaught errors -- these would be from the `get` method, not from the request itself */});
  }, []);
  return (/*...*/);
}

While this code isn’t very interesting, it becomes more so when you are in a live environment because of what you get from the Typescript compiler.

When the response comes back, we aren’t yet sure what it is, but by using a type guard (if (res.ok){}), we can discriminate and figure out if it’s a response or an error and get strong typing with that:

strongly typed response

If we didn’t have the type guard, we wouldn’t know if there was a data property on the response or not and line 11 would have complained.

Similarly, we know that data is of type Response (and therefore has a .json method on it) because we told get on line 7.

strongly typed errors

Very similarly, we have visibility into the types of errors we’re expecting (and you can imagine that this list can grow / shrink based on what your API returns). With this information, we’d be able to communicate differentially to the user based on what kind of error was occurring.

Note: One thing I wasn’t able to figure out with the native fetch implementation was the base URL. That said, I’m fairly certain I could update the request method to use a helper function and prepend a base URL if the URL provided is not fully qualified. This could be done using a similar approach to useAccessToken.

Here’s a Code Sandbox for playing around with. Note, it also has an Axios implementation for comparison.

Footnotes


Hi there and thanks for reading! My name's Stephen. I live in Chicago with my wife, Kate, and dog, Finn. Want more? See about and get in touch!