Crafting boring APIs: lessons learned from implementing fallback handlers in Pavex

The principle of least surprise is one of my north stars when designing APIs: an interface should behave exactly as most people would expect it to behave.

On the surface, it's easy to agree: it sounds sensible. In practice, it's damn hard work.
Every design decision turns into a deep dive through all the possible edge cases.

I've been recently struggling with it on one of my open source projects, and I feel like it could be useful to document my design thought process—hence this blog post!
I'll walk you through the design problem I'm working on, showcasing the different challenges when trying to nail down a "good API".

You can discuss this post on Reddit.

Table of Contents

The problem

I need to add fallback handlers to Pavex, the Rust web framework I'm developing.
A fallback handler is the function that will be invoked when an incoming request fails to match any of the routes you registered. E.g. you receive a GET /users but there is no /users entry in your router.

Every framework (including Pavex) provides a default handler: most return a 404 Not Found or a 405 Method Not Allowed response. 405 if there is another route that uses the same path but expects a different method (e.g. POST /users), 404 in all other cases.
That default does not work for everyone. Your API might be serving a server-side rendered website: your fallback should return a nice "Page not found" HTML page, ideally with some links to go back to the useful parts of your website.

You might have stricter requirements even if your API is designed to be consumed by other machines.
For example, your API schema might require all errors to return a structured payload (e.g. following the problem details RFC). An empty response is not OK.

Even worse (and the source of my current design dilemmas): you might be doing both things at once!
All your /api/* routes expose a JSON-based API, but all your /web/* routes are serving HTML pages. You need different fallbacks depending on the path of the incoming request.

Features interact

How do we accommodate these requirements?

The simplest option: add a fallback method to Pavex's Blueprint API, our routing interface. fallback would accept a request handler which would be automatically invoked whenever a request fails to route.
Things get tricky when we consider the interaction with other features.

Route nesting

Pavex allows you to break down your routing table into smaller ones. It helps in keeping your application modular.

For example, you can have separate api and web modules: they are entirely self-contained and you delegate to a top-level entrypoint the job of combining them. This is done via nesting:

bp.nest_at("/api", api_blueprint);
bp.nest_at("/web", web_blueprint);

It allows both sub-routers to ignore the fact that the other exists and that they need to "distinguish" themselves via a path prefix. That knowledge is pushed to the parent module.

bp, api_blueprint and web_blueprint are all instances of the same type in Pavex: Blueprint, the routing interface. If we add a fallback method to Blueprint, we are automatically allowing that method to be invoked by nested blueprints as well. What does that mean?

Working through the edge cases

Let's start from the easy case, our /api/* and /web/* nested routers.
If a request path begins with /api/ and it fails to match, what do we do?

Most obvious choice: invoke the fallback handler registered against api_blueprint.
If there isn't one, invoke the one registered against the top-level blueprint.
If there isn't one, the framework default.

There is no ambiguity because the nesting pattern creates a partition tree. All routes with the same prefix belong to the same sub-router.
That, unfortunately, is not always the case.

Let's look at this other routing configuration as an example:

bp.route(POST, "/users/:user_id/reset", reset_handler);
bp.nest_at("/users", users_bp);

You are importing a blueprint from a third-party user-management library, but you want to extend its functionality with an extra route.

What happens when a GET /users/12/boom request arrives?
users_bp no longer contains all routes with a /users/ prefix. Should the framework invoke the fallback defined on the top-level blueprint? Or the one defined in the nested blueprint?

It's not that clear-cut!

The solution space

We could solve the problem in different ways:

  1. define a resolution mechanism and add it to Pavex's documentation
  2. enforce that nesting results into a partitioned routing table
  3. reject fallback registrations that create routing ambiguity

Documentation

Documentation is "easy", but it doesn't satisfy me.
Users only reach for documentation if they are struggling to use the API or if they are trying to troubleshoot an issue.

Routing looks easy and familiar, you don't need to check the docs for it (beyond a few examples).

If you ran into ambiguity and that causes a bug, we've already caused you an issue. The documentation doesn't help in preventing it. It becomes an excuse for the framework, something to point at to say "you should have known better". Not great.

Enforced partitioning

What about enforcing a partitioned tree?
That could work, but it reduces the expressiveness of our routing API. The extension pattern I showed before would no longer be allowed.

What for? To have non-ambiguous fallback semantics in a few uncommon scenarios.

It violates another one of my favourite API design principles: simple things should be simple, complex things should be possible. We should avoid complicating everyone's life in order to accommodate a niche corner case.

Reject ambiguity

That leaves us with option 3: rejecting routers where we have fallback ambiguity.

We expect few people to run into this issue, therefore we are minimising the amount of discomfort for the average user. Since Pavex is built on top of a transpiler, we can also amortize the worst case scenario: we can reject the router at compile-time with a good error message, rather than failing at runtime when the application is initialised.
We can explain to the user where they should attach their fallback in order to remove the ambiguity, as well as showcase how they could rework their routing to be more partition-friendly.

It sounds like the most sensible option!

Closing thoughts

There's another element at play that we haven't discussed: implementation complexity.
Determining if we have fallback ambiguity is tricky. Which leads us to the last design principle of this journey: whenever a few can take on the pain of many, they should.

I hope this was insightful, and I want to close with an invitation: have you been working through a design puzzle? Why don't you share your thought process, if the project is out in the open1?

You can discuss this post on Reddit.


1

Yes, that's what RFCs are for! But most projects are not big enough to need them, which leaves a lot of interesting design dilemma undocumented.