CoolFace
Apppublic

strong-tie/inbound-calls

sourceHugging Faceupdated 2y agoView on Hugging Face
0likes
Delay-Accepting-Requests.md606 linesDownload Raw Back to Guides
1<h1 align="center">Fastify</h1>2 3# Delay Accepting Requests4 5## Introduction6 7Fastify provides several [hooks](../Reference/Hooks.md) useful for a variety of8situations. One of them is the [`onReady`](../Reference/Hooks.md#onready) hook,9which is useful for executing tasks *right before* the server starts accepting10new requests. There isn't, though, a direct mechanism to handle scenarios in11which you'd like the server to start accepting **specific** requests and denying12all others, at least up to some point.13 14Say, for instance, your server needs to authenticate with an OAuth provider to15start serving requests. To do that it'd need to engage in the [OAuth16Authorization Code17Flow](https://auth0.com/docs/get-started/authentication-and-authorization-flow/authorization-code-flow),18which would require it to listen to two requests from the authentication19provider:20 211. the Authorization Code webhook222. the tokens webhook23 24Until the authorization flow is done you wouldn't be able to serve customer25requests. What to do then?26 27There are several solutions for achieving that kind of behavior. Here we'll28introduce one of such techniques and, hopefully, you'll be able to get things29rolling asap!30 31## Solution32 33### Overview34 35The proposed solution is one of many possible ways of dealing with this scenario36and many similar to it. It relies solely on Fastify, so no fancy infrastructure37tricks or third-party libraries will be necessary.38 39To simplify things we won't be dealing with a precise OAuth flow but, instead,40simulate a scenario in which some key is needed to serve a request and that key41can only be retrieved in runtime by authenticating with an external provider.42 43The main goal here is to deny requests that would otherwise fail **as early as44possible** and with some **meaningful context**. That's both useful for the45server (fewer resources allocated to a bound-to-fail task) and for the client46(they get some meaningful information and don't need to wait long for it).47 48That will be achieved by wrapping into a custom plugin two main features:49 501. the mechanism for authenticating with the provider51[decorating](../Reference/Decorators.md) the `fastify` object with the52authentication key (`magicKey` from here onward)531. the mechanism for denying requests that would, otherwise, fail54 55### Hands-on56 57For this sample solution we'll be using the following:58 59- `node.js v16.14.2`60- `npm 8.5.0`61- `fastify 4.0.0-rc.1`62- `fastify-plugin 3.0.1`63- `undici 5.0.0`64 65Say we have the following base server set up at first:66 67```js68const Fastify = require('fastify')69 70const provider = require('./provider')71 72const server = Fastify({ logger: true })73const USUAL_WAIT_TIME_MS = 500074 75server.get('/ping', function (request, reply) {76  reply.send({ error: false, ready: request.server.magicKey !== null })77})78 79server.post('/webhook', function (request, reply) {80  // It's good practice to validate webhook requests really come from81  // whoever you expect. This is skipped in this sample for the sake82  // of simplicity83 84  const { magicKey } = request.body85  request.server.magicKey = magicKey86  request.log.info('Ready for customer requests!')87 88  reply.send({ error: false })89})90 91server.get('/v1*', async function (request, reply) {92  try {93    const data = await provider.fetchSensitiveData(request.server.magicKey)94    return { customer: true, error: false }95  } catch (error) {96    request.log.error({97      error,98      message: 'Failed at fetching sensitive data from provider',99    })100 101    reply.statusCode = 500102    return { customer: null, error: true }103  }104})105 106server.decorate('magicKey')107 108server.listen({ port: '1234' }, () => {109  provider.thirdPartyMagicKeyGenerator(USUAL_WAIT_TIME_MS)110    .catch((error) => {111      server.log.error({112        error,113        message: 'Got an error while trying to get the magic key!'114      })115 116      // Since we won't be able to serve requests, might as well wrap117      // things up118      server.close(() => process.exit(1))119    })120})121```122 123Our code is simply setting up a Fastify server with a few routes:124 125- a `/ping` route that specifies whether the service is ready or not to serve126requests by checking if the `magicKey` has been set up127- a `/webhook` endpoint for our provider to reach back to us when they're ready128to share the `magicKey`. The `magicKey` is, then, saved into the previously set129decorator on the `fastify` object130- a catchall `/v1*` route to simulate what would have been customer-initiated131requests. These requests rely on us having a valid `magicKey`132 133The `provider.js` file, simulating actions of an external provider, is as134follows:135 136```js137const { fetch } = require('undici')138const { setTimeout } = require('node:timers/promises')139 140const MAGIC_KEY = '12345'141 142const delay = setTimeout143 144exports.thirdPartyMagicKeyGenerator = async (ms) => {145  // Simulate processing delay146  await delay(ms)147 148  // Simulate webhook request to our server149  const { status } = await fetch(150    'http://localhost:1234/webhook',151    {152      body: JSON.stringify({ magicKey: MAGIC_KEY }),153      method: 'POST',154      headers: {155        'content-type': 'application/json',156      },157    },158  )159 160  if (status !== 200) {161    throw new Error('Failed to fetch magic key')162  }163}164 165exports.fetchSensitiveData = async (key) => {166  // Simulate processing delay167  await delay(700)168  const data = { sensitive: true }169 170  if (key === MAGIC_KEY) {171    return data172  }173 174  throw new Error('Invalid key')175}176```177 178The most important snippet here is the `thirdPartyMagicKeyGenerator` function,179which will wait for 5 seconds and, then, make the POST request to our `/webhook`180endpoint.181 182When our server spins up we start listening to new connections without having183our `magicKey` set up. Until we receive the webhook request from our external184provider (in this example we're simulating a 5 second delay) all our requests185under the `/v1*` path (customer requests) will fail. Worse than that: they'll186fail after we've reached out to our provider with an invalid key and got an187error from them. That wasted time and resources for us and our customers.188Depending on the kind of application we're running and on the request rate we're189expecting this delay is not acceptable or, at least, very annoying.190 191Of course, that could be simply mitigated by checking whether or not the192`magicKey` has been set up before hitting the provider in the `/v1*` handler.193Sure, but that would lead to bloat in the code. And imagine we have dozens of194different routes, with different controllers, that require that key. Should we195repeatedly add that check to all of them? That's error-prone and there are more196elegant solutions.197 198What we'll do to improve this setup overall is create a199[`Plugin`](../Reference/Plugins.md) that'll be solely responsible for making200sure we both:201 202- do not accept requests that would otherwise fail until we're ready for them203- make sure we reach out to our provider as soon as possible204 205This way we'll make sure all our setup regarding this specific _business rule_206is placed on a single entity, instead of scattered all across our code base.207 208With the changes to improve this behavior, the code will look like this:209 210##### index.js211 212```js213const Fastify = require('fastify')214 215const customerRoutes = require('./customer-routes')216const { setup, delay } = require('./delay-incoming-requests')217 218const server = new Fastify({ logger: true })219 220server.register(setup)221 222// Non-blocked URL223server.get('/ping', function (request, reply) {224  reply.send({ error: false, ready: request.server.magicKey !== null })225})226 227// Webhook to handle the provider's response - also non-blocked228server.post('/webhook', function (request, reply) {229  // It's good practice to validate webhook requests really come from230  // whoever you expect. This is skipped in this sample for the sake231  // of simplicity232 233  const { magicKey } = request.body234  request.server.magicKey = magicKey235  request.log.info('Ready for customer requests!')236 237  reply.send({ error: false })238})239 240// Blocked URLs241// Mind we're building a new plugin by calling the `delay` factory with our242// customerRoutes plugin243server.register(delay(customerRoutes), { prefix: '/v1' })244 245server.listen({ port: '1234' })246```247 248##### provider.js249 250```js251const { fetch } = require('undici')252const { setTimeout } = require('node:timers/promises')253 254const MAGIC_KEY = '12345'255 256const delay = setTimeout257 258exports.thirdPartyMagicKeyGenerator = async (ms) => {259  // Simulate processing delay260  await delay(ms)261 262  // Simulate webhook request to our server263  const { status } = await fetch(264    'http://localhost:1234/webhook',265    {266      body: JSON.stringify({ magicKey: MAGIC_KEY }),267      method: 'POST',268      headers: {269        'content-type': 'application/json',270      },271    },272  )273 274  if (status !== 200) {275    throw new Error('Failed to fetch magic key')276  }277}278 279exports.fetchSensitiveData = async (key) => {280  // Simulate processing delay281  await delay(700)282  const data = { sensitive: true }283 284  if (key === MAGIC_KEY) {285    return data286  }287 288  throw new Error('Invalid key')289}290```291 292##### delay-incoming-requests.js293 294```js295const fp = require('fastify-plugin')296 297const provider = require('./provider')298 299const USUAL_WAIT_TIME_MS = 5000300 301async function setup(fastify) {302  // As soon as we're listening for requests, let's work our magic303  fastify.server.on('listening', doMagic)304 305  // Set up the placeholder for the magicKey306  fastify.decorate('magicKey')307 308  // Our magic -- important to make sure errors are handled. Beware of async309  // functions outside `try/catch` blocks310  // If an error is thrown at this point and not captured it'll crash the311  // application312  function doMagic() {313    fastify.log.info('Doing magic!')314 315    provider.thirdPartyMagicKeyGenerator(USUAL_WAIT_TIME_MS)316      .catch((error) => {317        fastify.log.error({318          error,319          message: 'Got an error while trying to get the magic key!'320        })321 322        // Since we won't be able to serve requests, might as well wrap323        // things up324        fastify.close(() => process.exit(1))325      })326  }327}328 329const delay = (routes) =>330  function (fastify, opts, done) {331    // Make sure customer requests won't be accepted if the magicKey is not332    // available333    fastify.addHook('onRequest', function (request, reply, next) {334      if (!request.server.magicKey) {335        reply.statusCode = 503336        reply.header('Retry-After', USUAL_WAIT_TIME_MS)337        reply.send({ error: true, retryInMs: USUAL_WAIT_TIME_MS })338      }339 340      next()341    })342 343    // Register to-be-delayed routes344    fastify.register(routes, opts)345 346    done()347  }348 349module.exports = {350  setup: fp(setup),351  delay,352}353```354 355##### customer-routes.js356 357```js358const fp = require('fastify-plugin')359 360const provider = require('./provider')361 362module.exports = fp(async function (fastify) {363  fastify.get('*', async function (request ,reply) {364    try {365      const data = await provider.fetchSensitiveData(request.server.magicKey)366      return { customer: true, error: false }367    } catch (error) {368      request.log.error({369        error,370        message: 'Failed at fetching sensitive data from provider',371      })372 373      reply.statusCode = 500374      return { customer: null, error: true }375    }376  })377})378```379 380There is a very specific change on the previously existing files that is worth381mentioning: Beforehand we were using the `server.listen` callback to start the382authentication process with the external provider and we were decorating the383`server` object right before initializing the server. That was bloating our384server initialization setup with unnecessary code and didn't have much to do385with starting the Fastify server. It was a business logic that didn't have its386specific place in the code base.387 388Now we've implemented the `delayIncomingRequests` plugin in the389`delay-incoming-requests.js` file. That's, in truth, a module split into two390different plugins that will build up to a single use-case. That's the brains of391our operation. Let's walk through what the plugins do:392 393##### setup394 395The `setup` plugin is responsible for making sure we reach out to our provider396asap and store the `magicKey` somewhere available to all our handlers.397 398```js399  fastify.server.on('listening', doMagic)400```401 402As soon as the server starts listening (very similar behavior to adding a piece403of code to the `server.listen`'s callback function) a `listening` event is404emitted (for more info refer to405https://nodejs.org/api/net.html#event-listening). We use that to reach out to406our provider as soon as possible, with the `doMagic` function.407 408```js409  fastify.decorate('magicKey')410```411 412The `magicKey` decoration is also part of the plugin now. We initialize it with413a placeholder, waiting for the valid value to be retrieved.414 415##### delay416 417`delay` is not a plugin itself. It's actually a plugin *factory*. It expects a418Fastify plugin with `routes` and exports the actual plugin that'll handle419enveloping those routes with an `onRequest` hook that will make sure no requests420are handled until we're ready for them.421 422```js423const delay = (routes) =>424  function (fastify, opts, done) {425    // Make sure customer requests won't be accepted if the magicKey is not426    // available427    fastify.addHook('onRequest', function (request, reply, next) {428      if (!request.server.magicKey) {429        reply.statusCode = 503430        reply.header('Retry-After', USUAL_WAIT_TIME_MS)431        reply.send({ error: true, retryInMs: USUAL_WAIT_TIME_MS })432      }433 434      next()435    })436 437    // Register to-be-delayed routes438    fastify.register(routes, opts)439 440    done()441  }442```443 444Instead of updating every single controller that might use the `magicKey`, we445simply make sure that no route that's related to customer requests will be446served until we have everything ready. And there's more: we fail **FAST** and447have the possibility of giving the customer meaningful information, like how448long they should wait before retrying the request. Going even further, by449issuing a [`503` status450code](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/503) we're451signaling to our infrastructure components (namely load balancers) we're still452not ready to take incoming requests and they should redirect traffic to other453instances, if available, besides in how long we estimate that will be solved.454All of that in a few simple lines!455 456It's noteworthy that we didn't use the `fastify-plugin` wrapper in the `delay`457factory. That's because we wanted the `onRequest` hook to only be set within458that specific scope and not to the scope that called it (in our case, the main459`server` object defined in `index.js`). `fastify-plugin` sets the460`skip-override` hidden property, which has a practical effect of making whatever461changes we make to our `fastify` object available to the upper scope. That's462also why we used it with the `customerRoutes` plugin: we wanted those routes to463be available to its calling scope, the `delay` plugin. For more info on that464subject refer to [Plugins](../Reference/Plugins.md#handle-the-scope).465 466Let's see how that behaves in action. If we fired our server up with `node467index.js` and made a few requests to test things out. These were the logs we'd468see (some bloat was removed to ease things up):469 470<!-- markdownlint-disable -->471```sh472{"time":1650063793316,"msg":"Doing magic!"}473{"time":1650063793316,"msg":"Server listening at http://127.0.0.1:1234"}474{"time":1650063795030,"reqId":"req-1","req":{"method":"GET","url":"/v1","hostname":"localhost:1234","remoteAddress":"127.0.0.1","remotePort":51928},"msg":"incoming request"}475{"time":1650063795033,"reqId":"req-1","res":{"statusCode":503},"responseTime":2.5721680000424385,"msg":"request completed"}476{"time":1650063796248,"reqId":"req-2","req":{"method":"GET","url":"/ping","hostname":"localhost:1234","remoteAddress":"127.0.0.1","remotePort":51930},"msg":"incoming request"}477{"time":1650063796248,"reqId":"req-2","res":{"statusCode":200},"responseTime":0.4802369996905327,"msg":"request completed"}478{"time":1650063798377,"reqId":"req-3","req":{"method":"POST","url":"/webhook","hostname":"localhost:1234","remoteAddress":"127.0.0.1","remotePort":51932},"msg":"incoming request"}479{"time":1650063798379,"reqId":"req-3","msg":"Ready for customer requests!"}480{"time":1650063798379,"reqId":"req-3","res":{"statusCode":200},"responseTime":1.3567829988896847,"msg":"request completed"}481{"time":1650063799858,"reqId":"req-4","req":{"method":"GET","url":"/v1","hostname":"localhost:1234","remoteAddress":"127.0.0.1","remotePort":51934},"msg":"incoming request"}482{"time":1650063800561,"reqId":"req-4","res":{"statusCode":200},"responseTime":702.4662979990244,"msg":"request completed"}483```484<!-- markdownlint-enable -->485 486Let's focus on a few parts:487 488```sh489{"time":1650063793316,"msg":"Doing magic!"}490{"time":1650063793316,"msg":"Server listening at http://127.0.0.1:1234"}491```492 493These are the initial logs we'd see as soon as the server started. We reach out494to the external provider as early as possible within a valid time window (we495couldn't do that before the server was ready to receive connections).496 497While the server is still not ready, a few requests are attempted:498 499<!-- markdownlint-disable -->500```sh501{"time":1650063795030,"reqId":"req-1","req":{"method":"GET","url":"/v1","hostname":"localhost:1234","remoteAddress":"127.0.0.1","remotePort":51928},"msg":"incoming request"}502{"time":1650063795033,"reqId":"req-1","res":{"statusCode":503},"responseTime":2.5721680000424385,"msg":"request completed"}503{"time":1650063796248,"reqId":"req-2","req":{"method":"GET","url":"/ping","hostname":"localhost:1234","remoteAddress":"127.0.0.1","remotePort":51930},"msg":"incoming request"}504{"time":1650063796248,"reqId":"req-2","res":{"statusCode":200},"responseTime":0.4802369996905327,"msg":"request completed"}505```506<!-- markdownlint-enable -->507 508The first one (`req-1`) was a `GET /v1`, that failed (**FAST** - `responseTime`509is in `ms`) with our `503` status code and the meaningful information in the510response. Below is the response for that request:511 512```sh513HTTP/1.1 503 Service Unavailable514Connection: keep-alive515Content-Length: 31516Content-Type: application/json; charset=utf-8517Date: Fri, 15 Apr 2022 23:03:15 GMT518Keep-Alive: timeout=5519Retry-After: 5000520 521{522    "error": true,523    "retryInMs": 5000524}525```526 527Then we attempt a new request (`req-2`), which was a `GET /ping`. As expected,528since that was not one of the requests we asked our plugin to filter, it529succeeded. That could also be used as means of informing an interested party530whether or not we were ready to serve requests (although `/ping` is more531commonly associated with *liveness* checks and that would be the responsibility532of a *readiness* check -- the curious reader can get more info on these terms533[here](https://cloud.google.com/blog/products/containers-kubernetes/kubernetes-best-practices-setting-up-health-checks-with-readiness-and-liveness-probes))534with the `ready` field. Below is the response for that request:535 536```sh537HTTP/1.1 200 OK538Connection: keep-alive539Content-Length: 29540Content-Type: application/json; charset=utf-8541Date: Fri, 15 Apr 2022 23:03:16 GMT542Keep-Alive: timeout=5543 544{545    "error": false,546    "ready": false547}548```549 550After that there were more interesting log messages:551 552<!-- markdownlint-disable -->553```sh554{"time":1650063798377,"reqId":"req-3","req":{"method":"POST","url":"/webhook","hostname":"localhost:1234","remoteAddress":"127.0.0.1","remotePort":51932},"msg":"incoming request"}555{"time":1650063798379,"reqId":"req-3","msg":"Ready for customer requests!"}556{"time":1650063798379,"reqId":"req-3","res":{"statusCode":200},"responseTime":1.3567829988896847,"msg":"request completed"}557```558<!-- markdownlint-enable -->559 560This time it was our simulated external provider hitting us to let us know561authentication had gone well and telling us what our `magicKey` was. We saved562that into our `magicKey` decorator and celebrated with a log message saying we563were now ready for customers to hit us!564 565<!-- markdownlint-disable -->566```sh567{"time":1650063799858,"reqId":"req-4","req":{"method":"GET","url":"/v1","hostname":"localhost:1234","remoteAddress":"127.0.0.1","remotePort":51934},"msg":"incoming request"}568{"time":1650063800561,"reqId":"req-4","res":{"statusCode":200},"responseTime":702.4662979990244,"msg":"request completed"}569```570<!-- markdownlint-enable -->571 572Finally, a final `GET /v1` request was made and, this time, it succeeded. Its573response was the following:574 575```sh576HTTP/1.1 200 OK577Connection: keep-alive578Content-Length: 31579Content-Type: application/json; charset=utf-8580Date: Fri, 15 Apr 2022 23:03:20 GMT581Keep-Alive: timeout=5582 583{584    "customer": true,585    "error": false586}587```588 589## Conclusion590 591Specifics of the implementation will vary from one problem to another, but the592main goal of this guide was to show a very specific use case of an issue that593could be solved within Fastify's ecosystem.594 595This guide is a tutorial on the use of plugins, decorators, and hooks to solve596the problem of delaying serving specific requests on our application. It's not597production-ready, as it keeps local state (the `magicKey`) and it's not598horizontally scalable (we don't want to flood our provider, right?). One way of599improving it would be storing the `magicKey` somewhere else (perhaps a cache600database?).601 602The keywords here were [Decorators](../Reference/Decorators.md),603[Hooks](../Reference/Hooks.md), and [Plugins](../Reference/Plugins.md).604Combining what Fastify has to offer can lead to very ingenious and creative605solutions to a wide variety of problems. Let's be creative! :)606