strong-tie/inbound-calls
0
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 