Implementing Custom Transport Adapters in Requests

Implementing Custom Transport Adapters in Requests

When you’re working with complex applications, sometimes HTTP just doesn’t cut it. Sure, it’s the backbone of web communication, but there are scenarios where its stateless nature can really throw a wrench in your gears. Consider applications that require real-time updates or those that need to maintain state across sessions. In these cases, relying solely on HTTP can lead to significant inefficiencies.

Take a chat application, for example. HTTP requests can be cumbersome for sending and receiving messages in real-time. A better choice might be to leverage WebSockets, which maintain a persistent connection between the client and server. This allows for instantaneous exchange of messages without the overhead of constantly opening and closing connections.

const socket = new WebSocket('ws://example.com/socket');

socket.onopen = function(event) {
  console.log('Connection established!');
};

socket.onmessage = function(event) {
  console.log('Message from server: ', event.data);
};

function sendMessage(message) {
  socket.send(message);
}

WebSockets keep the connection alive, allowing for a two-way communication channel. This means your chat app can receive messages and update the UI without the need for the user to refresh or even click a button. Imagine how much smoother that experience is compared to polling a server every few seconds to check for new messages.

Moreover, there are scenarios where you might want to handle streaming data. Consider a live sports update app. With HTTP, you’d need to continuously request the latest scores, which can be taxing both on the server and the network. Instead, using technologies like Server-Sent Events (SSE) can push updates directly to the client as they happen.

const eventSource = new EventSource('/updates');

eventSource.onmessage = function(event) {
  console.log('New update: ', event.data);
};

This approach drastically reduces the amount of unnecessary traffic and provides a seamless experience for users who want to stay informed without constant interruptions. It’s just one of the many ways we can enhance user experience by moving beyond the limitations of HTTP.

But let’s not forget that while these solutions are powerful, they also introduce complexity. Managing state and connections requires careful consideration. It’s easy to get caught up in the excitement of new technologies, but we must ensure that we’re not adding unnecessary complexity to our applications.

To sum it up, when you’re building applications that demand more than what HTTP can provide, look into alternatives like WebSockets and SSE. They can make a significant difference in performance and user experience, but they also come with their own set of challenges that you’ll need to tackle head-on.

It’s just a class with a couple of methods, really

So you decide to build a wrapper around the native fetch API to handle all your data fetching needs. The goal is to create a simple, reusable component that abstracts away the repetitive parts of making HTTP requests. You end up with something that looks, on the surface, incredibly straightforward. It’s just a class. You instantiate it, and it has a method called get. What could be simpler?

class ApiClient {
  constructor(config) {
    this.baseUrl = config.baseUrl;
  }

  async get(endpoint) {
    const response = await fetch(${this.baseUrl}/${endpoint});
    if (!response.ok) {
      // A very basic error handling
      throw new Error(Request failed with status ${response.status});
    }
    return response.json();
  }
}

// Usage:
const client = new ApiClient({ baseUrl: 'https://api.example.com' });
const users = await client.get('users');

And for a while, this is great. You’ve successfully hidden the boilerplate. No more typing out the full URL, no more manually checking response.ok, no more calling .json() every time. You’ve created a beautiful little abstraction. All the messiness of the underlying HTTP mechanism is tucked away behind a clean, elegant interface. It’s just a class with a couple of methods, really. This is the bread and butter of software engineering: we build boxes on top of other boxes.

But then the product manager comes along and says, “We need to add authentication to all our API calls.” Okay, no problem. We can add that to our ApiClient. We’ll just pass in a token and add an Authorization header. The public method signature doesn’t even have to change. The complexity is contained within our little box.

class ApiClient {
  constructor(config) {
    this.baseUrl = config.baseUrl;
    this.token = config.token;
  }

  async get(endpoint) {
    const headers = {
      'Authorization': Bearer ${this.token}
    };
    const response = await fetch(${this.baseUrl}/${endpoint}, { headers });
    if (!response.ok) {
      throw new Error(Request failed with status ${response.status});
    }
    return response.json();
  }
}

See? The calling code, client.get('users'), remains blissfully unaware of the new authentication logic. This is the power of abstraction at work. We’ve added a significant new feature without disturbing the rest of the application. Then the next feature request comes in: “The network is sometimes flaky. Can we automatically retry failed requests?” Sure. We can add a retry mechanism with exponential backoff inside our get method. The complexity of our little box grows, but the interface it presents to the world remains simple and clean. The consumer of our ApiClient doesn’t need to know or care about retry logic; it just knows that when it calls get, it either gets data or a final, definitive error.

This is how we manage to build large, complex systems without our heads exploding. We rely on these abstractions, these promises that a simple interface will reliably perform a complex task. We trust that the File.Open method handles the thousands of interactions with the filesystem driver and the operating system kernel, and we don’t have to think about inodes or disk sectors. We just get a file handle. It’s a beautiful, elegant lie that we tell ourselves to stay productive. The problem, of course, is that all abstractions are, to some extent, leaky.

The law of leaky abstractions strikes again

And then the leak starts. It’s not a big one at first. Just a trickle. The product manager comes back and says, “When a user’s session expires, the API returns a 401 Unauthorized status. We need to catch that and redirect the user to the login page. But if the server has a bug and returns a 500 Internal Server Error, we should just show a generic ‘Oops, something went wrong’ message.” Your current abstraction can’t do this. It just throws a generic Error. You have no way of knowing what the HTTP status code was.

So you have to go back and modify your beautiful, simple box. The abstraction has to leak some of the underlying details back to the caller. You decide to create a custom error class that includes the full response object, so the caller can inspect it.

class HttpError extends Error {
  constructor(response) {
    super(Request failed with status ${response.status});
    this.name = 'HttpError';
    this.response = response;
    this.status = response.status;
  }
}

class ApiClient {
  constructor(config) {
    this.baseUrl = config.baseUrl;
    this.token = config.token;
  }

  async get(endpoint) {
    const headers = { 'Authorization': Bearer ${this.token} };
    const response = await fetch(${this.baseUrl}/${endpoint}, { headers });
    if (!response.ok) {
      throw new HttpError(response);
    }
    return response.json();
  }
}

Now the calling code has to change. It’s no longer a simple one-liner. The developer using your ApiClient now has to know about your custom HttpError class, and they have to write a try...catch block to inspect the HTTP status code. The abstraction has sprung a leak. The promise of “just get me the data” has been amended with a footnote: “…and if it fails, you’d better know how to read HTTP status codes to figure out why.”

try {
  const data = await client.get('user/profile');
  // render profile
} catch (error) {
  if (error instanceof HttpError && error.status === 401) {
    // redirect to login page
    window.location.href = '/login';
  } else {
    // show generic error message
    showGenericError();
  }
}

This is annoying, but manageable. But the leaks never stop. The next requirement is for a search-as-you-type input field. As the user types, you fire off API requests. But if the user types quickly, you want to cancel the previous, now-obsolete request to avoid unnecessary network traffic and to prevent an old result from overwriting a new one. The fetch API supports this with something called an AbortController. But to use it, you have to pass a signal object into the fetch options. This means your simple get(endpoint) method signature is no longer sufficient.

You have to poke another hole in your abstraction to let the caller pass in an abort signal. Your method signature becomes get(endpoint, options).

class ApiClient {
  // ... constructor ...

  async get(endpoint, options = {}) {
    const { signal } = options;
    const headers = { 'Authorization': Bearer ${this.token} };
    const response = await fetch(${this.baseUrl}/${endpoint}, { headers, signal });
    if (!response.ok) {
      throw new HttpError(response);
    }
    return response.json();
  }
}

// Calling code for the search feature
let controller;
searchInput.addEventListener('input', (e) => {
  // If there's an ongoing request, abort it.
  if (controller) {
    controller.abort();
  }
  controller = new AbortController();
  const signal = controller.signal;

  client.get(search?q=${e.target.value}, { signal })
    .then(results => {
      // display results
    })
    .catch(error => {
      // An aborted fetch throws an error, so we need to ignore it.
      if (error.name === 'AbortError') {
        console.log('Fetch aborted');
      } else {
        // handle other errors
      }
    });
});

Look at what’s happened. The complexity you so cleverly hid inside your ApiClient has now completely spilled out. The developer using your class not only needs to know about HTTP status codes, but now they also have to instantiate and manage the lifecycle of an AbortController. They have to know that they need to pass its signal property into your method. They have to know that a cancelled request will throw a specific type of error, an AbortError, which they must catch and ignore. The abstraction is now so leaky it’s practically a sieve. All the messy details of the underlying fetch API are right there, exposed for the consumer of your class to deal with. This is the Law of Leaky Abstractions in action: all non-trivial abstractions, to some degree, are leaky. You can’t paper over the complexities of the underlying system forever. Eventually, the user of your abstraction will be forced to understand the details you were trying to hide.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *