Delay Accepting Requests

Introduction

Fastify provides several hooks useful for a variety of situations. One of them is the onReady hook, which is useful for executing tasks right before the server starts accepting new requests. There isn’t, though, a direct mechanism to handle scenarios in which you’d like the server to start accepting specific requests and denying all others, at least up to some point.

Say, for instance, your server needs to authenticate with an OAuth provider to start serving requests. To do that it’d need to engage in the OAuth Authorization Code Flow, which would require it to listen to two requests from the authentication provider:

  1. the Authorization Code webhook
  2. the tokens webhook

Until the authorization flow is done you wouldn’t be able to serve customer requests. What to do then?

There are several solutions for achieving that kind of behavior. Here we’ll introduce one of such techniques and, hopefully, you’ll be able to get things rolling asap!

Solution

Overview

The proposed solution is one of many possible ways of dealing with this scenario and many similar to it. It relies solely on Fastify, so no fancy infrastructure tricks or third-party libraries will be necessary.

To simplify things we won’t be dealing with a precise OAuth flow but, instead, simulate a scenario in which some key is needed to serve a request and that key can only be retrieved in runtime by authenticating with an external provider.

The main goal here is to deny requests that would otherwise fail as early as possible and with some meaningful context. That’s both useful for the server (fewer resources allocated to a bound-to-fail task) and for the client (they get some meaningful information and don’t need to wait long for it).

That will be achieved by wrapping into a custom plugin two main features:

  1. the mechanism for authenticating with the provider decorating the fastify object with the authentication key (magicKey from here onward)
  2. the mechanism for denying requests that would, otherwise, fail

Hands-on

For this sample solution we’ll be using the following:

  • node.js v16.14.2
  • npm 8.5.0
  • fastify 4.0.0-rc.1
  • fastify-plugin 3.0.1
  • undici 5.0.0

Say we have the following base server set up at first:

  1. const Fastify = require('fastify')
  2. const provider = require('./provider')
  3. const server = Fastify({ logger: true })
  4. const USUAL_WAIT_TIME_MS = 5000
  5. server.get('/ping', function (request, reply) {
  6. reply.send({ error: false, ready: request.server.magicKey !== null })
  7. })
  8. server.post('/webhook', function (request, reply) {
  9. // It's good practice to validate webhook requests really come from
  10. // whoever you expect. This is skipped in this sample for the sake
  11. // of simplicity
  12. const { magicKey } = request.body
  13. request.server.magicKey = magicKey
  14. request.log.info('Ready for customer requests!')
  15. reply.send({ error: false })
  16. })
  17. server.get('/v1*', async function (request, reply) {
  18. try {
  19. const data = await provider.fetchSensitiveData(request.server.magicKey)
  20. return { customer: true, error: false }
  21. } catch (error) {
  22. request.log.error({
  23. error,
  24. message: 'Failed at fetching sensitive data from provider',
  25. })
  26. reply.statusCode = 500
  27. return { customer: null, error: true }
  28. }
  29. })
  30. server.decorate('magicKey', null)
  31. server.listen({ port: '1234' }, () => {
  32. provider.thirdPartyMagicKeyGenerator(USUAL_WAIT_TIME_MS)
  33. .catch((error) => {
  34. server.log.error({
  35. error,
  36. message: 'Got an error while trying to get the magic key!'
  37. })
  38. // Since we won't be able to serve requests, might as well wrap
  39. // things up
  40. server.close(() => process.exit(1))
  41. })
  42. })

Our code is simply setting up a Fastify server with a few routes:

  • a /ping route that specifies whether the service is ready or not to serve requests by checking if the magicKey has been set up
  • a /webhook endpoint for our provider to reach back to us when they’re ready to share the magicKey. The magicKey is, then, saved into the previously set decorator on the fastify object
  • a catchall /v1* route to simulate what would have been customer-initiated requests. These requests rely on us having a valid magicKey

The provider.js file, simulating actions of an external provider, is as follows:

  1. const { fetch } = require('undici')
  2. const { setTimeout } = require('node:timers/promises')
  3. const MAGIC_KEY = '12345'
  4. const delay = setTimeout
  5. exports.thirdPartyMagicKeyGenerator = async (ms) => {
  6. // Simulate processing delay
  7. await delay(ms)
  8. // Simulate webhook request to our server
  9. const { status } = await fetch(
  10. 'http://localhost:1234/webhook',
  11. {
  12. body: JSON.stringify({ magicKey: MAGIC_KEY }),
  13. method: 'POST',
  14. headers: {
  15. 'content-type': 'application/json',
  16. },
  17. },
  18. )
  19. if (status !== 200) {
  20. throw new Error('Failed to fetch magic key')
  21. }
  22. }
  23. exports.fetchSensitiveData = async (key) => {
  24. // Simulate processing delay
  25. await delay(700)
  26. const data = { sensitive: true }
  27. if (key === MAGIC_KEY) {
  28. return data
  29. }
  30. throw new Error('Invalid key')
  31. }

The most important snippet here is the thirdPartyMagicKeyGenerator function, which will wait for 5 seconds and, then, make the POST request to our /webhook endpoint.

When our server spins up we start listening to new connections without having our magicKey set up. Until we receive the webhook request from our external provider (in this example we’re simulating a 5 second delay) all our requests under the /v1* path (customer requests) will fail. Worse than that: they’ll fail after we’ve reached out to our provider with an invalid key and got an error from them. That wasted time and resources for us and our customers. Depending on the kind of application we’re running and on the request rate we’re expecting this delay is not acceptable or, at least, very annoying.

Of course, that could be simply mitigated by checking whether or not the magicKey has been set up before hitting the provider in the /v1* handler. Sure, but that would lead to bloat in the code. And imagine we have dozens of different routes, with different controllers, that require that key. Should we repeatedly add that check to all of them? That’s error-prone and there are more elegant solutions.

What we’ll do to improve this setup overall is create a Plugin that’ll be solely responsible for making sure we both:

  • do not accept requests that would otherwise fail until we’re ready for them
  • make sure we reach out to our provider as soon as possible

This way we’ll make sure all our setup regarding this specific business rule is placed on a single entity, instead of scattered all across our code base.

With the changes to improve this behavior, the code will look like this:

index.js
  1. const Fastify = require('fastify')
  2. const customerRoutes = require('./customer-routes')
  3. const { setup, delay } = require('./delay-incoming-requests')
  4. const server = new Fastify({ logger: true })
  5. server.register(setup)
  6. // Non-blocked URL
  7. server.get('/ping', function (request, reply) {
  8. reply.send({ error: false, ready: request.server.magicKey !== null })
  9. })
  10. // Webhook to handle the provider's response - also non-blocked
  11. server.post('/webhook', function (request, reply) {
  12. // It's good practice to validate webhook requests really come from
  13. // whoever you expect. This is skipped in this sample for the sake
  14. // of simplicity
  15. const { magicKey } = request.body
  16. request.server.magicKey = magicKey
  17. request.log.info('Ready for customer requests!')
  18. reply.send({ error: false })
  19. })
  20. // Blocked URLs
  21. // Mind we're building a new plugin by calling the `delay` factory with our
  22. // customerRoutes plugin
  23. server.register(delay(customerRoutes), { prefix: '/v1' })
  24. server.listen({ port: '1234' })
provider.js
  1. const { fetch } = require('undici')
  2. const { setTimeout } = require('node:timers/promises')
  3. const MAGIC_KEY = '12345'
  4. const delay = setTimeout
  5. exports.thirdPartyMagicKeyGenerator = async (ms) => {
  6. // Simulate processing delay
  7. await delay(ms)
  8. // Simulate webhook request to our server
  9. const { status } = await fetch(
  10. 'http://localhost:1234/webhook',
  11. {
  12. body: JSON.stringify({ magicKey: MAGIC_KEY }),
  13. method: 'POST',
  14. headers: {
  15. 'content-type': 'application/json',
  16. },
  17. },
  18. )
  19. if (status !== 200) {
  20. throw new Error('Failed to fetch magic key')
  21. }
  22. }
  23. exports.fetchSensitiveData = async (key) => {
  24. // Simulate processing delay
  25. await delay(700)
  26. const data = { sensitive: true }
  27. if (key === MAGIC_KEY) {
  28. return data
  29. }
  30. throw new Error('Invalid key')
  31. }
delay-incoming-requests.js
  1. const fp = require('fastify-plugin')
  2. const provider = require('./provider')
  3. const USUAL_WAIT_TIME_MS = 5000
  4. async function setup(fastify) {
  5. // As soon as we're listening for requests, let's work our magic
  6. fastify.server.on('listening', doMagic)
  7. // Set up the placeholder for the magicKey
  8. fastify.decorate('magicKey', null)
  9. // Our magic -- important to make sure errors are handled. Beware of async
  10. // functions outside `try/catch` blocks
  11. // If an error is thrown at this point and not captured it'll crash the
  12. // application
  13. function doMagic() {
  14. fastify.log.info('Doing magic!')
  15. provider.thirdPartyMagicKeyGenerator(USUAL_WAIT_TIME_MS)
  16. .catch((error) => {
  17. fastify.log.error({
  18. error,
  19. message: 'Got an error while trying to get the magic key!'
  20. })
  21. // Since we won't be able to serve requests, might as well wrap
  22. // things up
  23. fastify.close(() => process.exit(1))
  24. })
  25. }
  26. }
  27. const delay = (routes) =>
  28. function (fastify, opts, done) {
  29. // Make sure customer requests won't be accepted if the magicKey is not
  30. // available
  31. fastify.addHook('onRequest', function (request, reply, next) {
  32. if (!request.server.magicKey) {
  33. reply.statusCode = 503
  34. reply.header('Retry-After', USUAL_WAIT_TIME_MS)
  35. reply.send({ error: true, retryInMs: USUAL_WAIT_TIME_MS })
  36. }
  37. next()
  38. })
  39. // Register to-be-delayed routes
  40. fastify.register(routes, opts)
  41. done()
  42. }
  43. module.exports = {
  44. setup: fp(setup),
  45. delay,
  46. }
customer-routes.js
  1. const fp = require('fastify-plugin')
  2. const provider = require('./provider')
  3. module.exports = fp(async function (fastify) {
  4. fastify.get('*', async function (request ,reply) {
  5. try {
  6. const data = await provider.fetchSensitiveData(request.server.magicKey)
  7. return { customer: true, error: false }
  8. } catch (error) {
  9. request.log.error({
  10. error,
  11. message: 'Failed at fetching sensitive data from provider',
  12. })
  13. reply.statusCode = 500
  14. return { customer: null, error: true }
  15. }
  16. })
  17. })

There is a very specific change on the previously existing files that is worth mentioning: Beforehand we were using the server.listen callback to start the authentication process with the external provider and we were decorating the server object right before initializing the server. That was bloating our server initialization setup with unnecessary code and didn’t have much to do with starting the Fastify server. It was a business logic that didn’t have its specific place in the code base.

Now we’ve implemented the delayIncomingRequests plugin in the delay-incoming-requests.js file. That’s, in truth, a module split into two different plugins that will build up to a single use-case. That’s the brains of our operation. Let’s walk through what the plugins do:

setup

The setup plugin is responsible for making sure we reach out to our provider asap and store the magicKey somewhere available to all our handlers.

  1. fastify.server.on('listening', doMagic)

As soon as the server starts listening (very similar behavior to adding a piece of code to the server.listen‘s callback function) a listening event is emitted (for more info refer to https://nodejs.org/api/net.html#event-listening). We use that to reach out to our provider as soon as possible, with the doMagic function.

  1. fastify.decorate('magicKey', null)

The magicKey decoration is also part of the plugin now. We initialize it with a placeholder, waiting for the valid value to be retrieved.

delay

delay is not a plugin itself. It’s actually a plugin factory. It expects a Fastify plugin with routes and exports the actual plugin that’ll handle enveloping those routes with an onRequest hook that will make sure no requests are handled until we’re ready for them.

  1. const delay = (routes) =>
  2. function (fastify, opts, done) {
  3. // Make sure customer requests won't be accepted if the magicKey is not
  4. // available
  5. fastify.addHook('onRequest', function (request, reply, next) {
  6. if (!request.server.magicKey) {
  7. reply.statusCode = 503
  8. reply.header('Retry-After', USUAL_WAIT_TIME_MS)
  9. reply.send({ error: true, retryInMs: USUAL_WAIT_TIME_MS })
  10. }
  11. next()
  12. })
  13. // Register to-be-delayed routes
  14. fastify.register(routes, opts)
  15. done()
  16. }

Instead of updating every single controller that might use the magicKey, we simply make sure that no route that’s related to customer requests will be served until we have everything ready. And there’s more: we fail FAST and have the possibility of giving the customer meaningful information, like how long they should wait before retrying the request. Going even further, by issuing a 503 status code we’re signaling to our infrastructure components (namely load balancers) we’re still not ready to take incoming requests and they should redirect traffic to other instances, if available, besides in how long we estimate that will be solved. All of that in a few simple lines!

It’s noteworthy that we didn’t use the fastify-plugin wrapper in the delay factory. That’s because we wanted the onRequest hook to only be set within that specific scope and not to the scope that called it (in our case, the main server object defined in index.js). fastify-plugin sets the skip-override hidden property, which has a practical effect of making whatever changes we make to our fastify object available to the upper scope. That’s also why we used it with the customerRoutes plugin: we wanted those routes to be available to its calling scope, the delay plugin. For more info on that subject refer to Plugins.

Let’s see how that behaves in action. If we fired our server up with node index.js and made a few requests to test things out. These were the logs we’d see (some bloat was removed to ease things up):

  1. {"time":1650063793316,"msg":"Doing magic!"}
  2. {"time":1650063793316,"msg":"Server listening at http://127.0.0.1:1234"}
  3. {"time":1650063795030,"reqId":"req-1","req":{"method":"GET","url":"/v1","hostname":"localhost:1234","remoteAddress":"127.0.0.1","remotePort":51928},"msg":"incoming request"}
  4. {"time":1650063795033,"reqId":"req-1","res":{"statusCode":503},"responseTime":2.5721680000424385,"msg":"request completed"}
  5. {"time":1650063796248,"reqId":"req-2","req":{"method":"GET","url":"/ping","hostname":"localhost:1234","remoteAddress":"127.0.0.1","remotePort":51930},"msg":"incoming request"}
  6. {"time":1650063796248,"reqId":"req-2","res":{"statusCode":200},"responseTime":0.4802369996905327,"msg":"request completed"}
  7. {"time":1650063798377,"reqId":"req-3","req":{"method":"POST","url":"/webhook","hostname":"localhost:1234","remoteAddress":"127.0.0.1","remotePort":51932},"msg":"incoming request"}
  8. {"time":1650063798379,"reqId":"req-3","msg":"Ready for customer requests!"}
  9. {"time":1650063798379,"reqId":"req-3","res":{"statusCode":200},"responseTime":1.3567829988896847,"msg":"request completed"}
  10. {"time":1650063799858,"reqId":"req-4","req":{"method":"GET","url":"/v1","hostname":"localhost:1234","remoteAddress":"127.0.0.1","remotePort":51934},"msg":"incoming request"}
  11. {"time":1650063800561,"reqId":"req-4","res":{"statusCode":200},"responseTime":702.4662979990244,"msg":"request completed"}

Let’s focus on a few parts:

  1. {"time":1650063793316,"msg":"Doing magic!"}
  2. {"time":1650063793316,"msg":"Server listening at http://127.0.0.1:1234"}

These are the initial logs we’d see as soon as the server started. We reach out to the external provider as early as possible within a valid time window (we couldn’t do that before the server was ready to receive connections).

While the server is still not ready, a few requests are attempted:

  1. {"time":1650063795030,"reqId":"req-1","req":{"method":"GET","url":"/v1","hostname":"localhost:1234","remoteAddress":"127.0.0.1","remotePort":51928},"msg":"incoming request"}
  2. {"time":1650063795033,"reqId":"req-1","res":{"statusCode":503},"responseTime":2.5721680000424385,"msg":"request completed"}
  3. {"time":1650063796248,"reqId":"req-2","req":{"method":"GET","url":"/ping","hostname":"localhost:1234","remoteAddress":"127.0.0.1","remotePort":51930},"msg":"incoming request"}
  4. {"time":1650063796248,"reqId":"req-2","res":{"statusCode":200},"responseTime":0.4802369996905327,"msg":"request completed"}

The first one (req-1) was a GET /v1, that failed (FAST - responseTime is in ms) with our 503 status code and the meaningful information in the response. Below is the response for that request:

  1. HTTP/1.1 503 Service Unavailable
  2. Connection: keep-alive
  3. Content-Length: 31
  4. Content-Type: application/json; charset=utf-8
  5. Date: Fri, 15 Apr 2022 23:03:15 GMT
  6. Keep-Alive: timeout=5
  7. Retry-After: 5000
  8. {
  9. "error": true,
  10. "retryInMs": 5000
  11. }

Then we attempt a new request (req-2), which was a GET /ping. As expected, since that was not one of the requests we asked our plugin to filter, it succeeded. That could also be used as means of informing an interested party whether or not we were ready to serve requests (although /ping is more commonly associated with liveness checks and that would be the responsibility of a readiness check — the curious reader can get more info on these terms here) with the ready field. Below is the response for that request:

  1. HTTP/1.1 200 OK
  2. Connection: keep-alive
  3. Content-Length: 29
  4. Content-Type: application/json; charset=utf-8
  5. Date: Fri, 15 Apr 2022 23:03:16 GMT
  6. Keep-Alive: timeout=5
  7. {
  8. "error": false,
  9. "ready": false
  10. }

After that there were more interesting log messages:

  1. {"time":1650063798377,"reqId":"req-3","req":{"method":"POST","url":"/webhook","hostname":"localhost:1234","remoteAddress":"127.0.0.1","remotePort":51932},"msg":"incoming request"}
  2. {"time":1650063798379,"reqId":"req-3","msg":"Ready for customer requests!"}
  3. {"time":1650063798379,"reqId":"req-3","res":{"statusCode":200},"responseTime":1.3567829988896847,"msg":"request completed"}

This time it was our simulated external provider hitting us to let us know authentication had gone well and telling us what our magicKey was. We saved that into our magicKey decorator and celebrated with a log message saying we were now ready for customers to hit us!

  1. {"time":1650063799858,"reqId":"req-4","req":{"method":"GET","url":"/v1","hostname":"localhost:1234","remoteAddress":"127.0.0.1","remotePort":51934},"msg":"incoming request"}
  2. {"time":1650063800561,"reqId":"req-4","res":{"statusCode":200},"responseTime":702.4662979990244,"msg":"request completed"}

Finally, a final GET /v1 request was made and, this time, it succeeded. Its response was the following:

  1. HTTP/1.1 200 OK
  2. Connection: keep-alive
  3. Content-Length: 31
  4. Content-Type: application/json; charset=utf-8
  5. Date: Fri, 15 Apr 2022 23:03:20 GMT
  6. Keep-Alive: timeout=5
  7. {
  8. "customer": true,
  9. "error": false
  10. }

Conclusion

Specifics of the implementation will vary from one problem to another, but the main goal of this guide was to show a very specific use case of an issue that could be solved within Fastify’s ecosystem.

This guide is a tutorial on the use of plugins, decorators, and hooks to solve the problem of delaying serving specific requests on our application. It’s not production-ready, as it keeps local state (the magicKey) and it’s not horizontally scalable (we don’t want to flood our provider, right?). One way of improving it would be storing the magicKey somewhere else (perhaps a cache database?).

The keywords here were Decorators, Hooks, and Plugins. Combining what Fastify has to offer can lead to very ingenious and creative solutions to a wide variety of problems. Let’s be creative! :)