Skip to content

Commit fd7d0ee

Browse files
authored
Add timeout and abort middleware and processing halts (#18177)
* Add middleware to timeout requests after a period * Add halt-on-dropped-connection middleware to stop the middleware processing stack if the connection was already dropped * Add a few strategic bail-out spots for dropped connections during the render-page middleware * Handle 404s and HEAD requests earlier in the page rendering flow * Add a few more strategic bail-out spots for dropped connections during the render-page middleware * Add middleware to notice aborted requests * Add a check for aborted requests into the isConnectionDropped logic * Reformat comment for consistency * Handle aborted requests correctly in the error handler * Explicit returns for consistency
1 parent 1ee4d3f commit fd7d0ee

File tree

8 files changed

+118
-21
lines changed

8 files changed

+118
-21
lines changed

middleware/abort.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
module.exports = function (req, res, next) {
2+
// If the client aborts the connection, send an error
3+
req.once('aborted', () => {
4+
// NOTE: Node.js will also automatically set `req.aborted = true`
5+
6+
const abortError = new Error('Client closed request')
7+
abortError.statusCode = 499
8+
abortError.code = 'ECONNRESET'
9+
10+
// Pass the error to the Express error handler
11+
return next(abortError)
12+
})
13+
14+
return next()
15+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
function isConnectionDropped (req, res) {
2+
// Have the flags been set for:
3+
// - a global request timeout (via the express-timeout-handler middleware)?
4+
// - an aborted request connection (via Node.js core's HTTP IncomingMessage)?
5+
return Boolean(res.globalTimeout || req.aborted)
6+
}
7+
8+
function haltOnDroppedConnection (req, res, next) {
9+
// Only proceed if the flag has not been set for the express-timeout-handler middleware
10+
if (!isConnectionDropped(req, res)) {
11+
return next()
12+
}
13+
}
14+
15+
// Export this logic, too
16+
haltOnDroppedConnection.isConnectionDropped = isConnectionDropped
17+
18+
module.exports = haltOnDroppedConnection

middleware/handle-errors.js

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ const loadSiteData = require('../lib/site-data')
66
function shouldLogException (error) {
77
const IGNORED_ERRORS = [
88
// avoid sending CSRF token errors (from bad-actor POST requests)
9-
'EBADCSRFTOKEN'
9+
'EBADCSRFTOKEN',
10+
// Client connected aborted
11+
'ECONNRESET'
1012
]
1113

1214
if (IGNORED_ERRORS.includes(error.code)) {
@@ -26,8 +28,8 @@ async function logException (error, req) {
2628
}
2729

2830
module.exports = async function handleError (error, req, res, next) {
29-
// If the headers have already been sent...
30-
if (res.headersSent) {
31+
// If the headers have already been sent or the request was aborted...
32+
if (res.headersSent || req.aborted) {
3133
// Report to Failbot
3234
await logException(error, req)
3335

middleware/index.js

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
const express = require('express')
22
const instrument = require('../lib/instrument-middleware')
3+
const haltOnDroppedConnection = require('./halt-on-dropped-connection')
34

45
const isDevelopment = process.env.NODE_ENV === 'development'
56

@@ -11,6 +12,10 @@ const asyncMiddleware = fn =>
1112
}
1213

1314
module.exports = function (app) {
15+
// *** Request connection management ***
16+
app.use(require('./timeout'))
17+
app.use(require('./abort'))
18+
1419
// *** Development tools ***
1520
app.use(require('morgan')('dev', { skip: (req, res) => !isDevelopment }))
1621
if (isDevelopment) app.use(require('./webpack'))
@@ -60,6 +65,9 @@ module.exports = function (app) {
6065
app.use(instrument('./find-page')) // Must come before archived-enterprise-versions, breadcrumbs, featured-links, products, render-page
6166
app.use(instrument('./block-robots'))
6267

68+
// Check for a dropped connection before proceeding
69+
app.use(haltOnDroppedConnection)
70+
6371
// *** Rendering, 2xx responses ***
6472
// I largely ordered these by use frequency
6573
app.use(instrument('./archived-enterprise-versions-assets')) // Must come before static/assets
@@ -91,6 +99,9 @@ module.exports = function (app) {
9199
app.use(instrument('./loaderio-verification'))
92100
app.get('/_500', asyncMiddleware(instrument('./trigger-error')))
93101

102+
// Check for a dropped connection before proceeding (again)
103+
app.use(haltOnDroppedConnection)
104+
94105
// *** Preparation for render-page ***
95106
app.use(asyncMiddleware(instrument('./contextualizers/enterprise-release-notes')))
96107
app.use(instrument('./contextualizers/graphql'))
@@ -106,7 +117,12 @@ module.exports = function (app) {
106117
// *** Headers for pages only ***
107118
app.use(require('./set-fastly-cache-headers'))
108119

109-
// *** Rendering, must go last ***
120+
// Check for a dropped connection before proceeding (again)
121+
app.use(haltOnDroppedConnection)
122+
123+
// *** Rendering, must go almost last ***
110124
app.get('/*', asyncMiddleware(instrument('./render-page')))
125+
126+
// *** Error handling, must go last ***
111127
app.use(require('./handle-errors'))
112128
}

middleware/render-page.js

Lines changed: 30 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const getMiniTocItems = require('../lib/get-mini-toc-items')
66
const Page = require('../lib/page')
77
const statsd = require('../lib/statsd')
88
const RedisAccessor = require('../lib/redis-accessor')
9+
const { isConnectionDropped } = require('./halt-on-dropped-connection')
910

1011
const { HEROKU_RELEASE_VERSION } = process.env
1112
const pageCacheDatabaseNumber = 1
@@ -28,6 +29,23 @@ function addCsrf (req, text) {
2829
module.exports = async function renderPage (req, res, next) {
2930
const page = req.context.page
3031

32+
// render a 404 page
33+
if (!page) {
34+
if (process.env.NODE_ENV !== 'test' && req.context.redirectNotFound) {
35+
console.error(`\nTried to redirect to ${req.context.redirectNotFound}, but that page was not found.\n`)
36+
}
37+
return res.status(404).send(
38+
addCsrf(
39+
req,
40+
await liquid.parseAndRender(layouts['error-404'], req.context)
41+
)
42+
)
43+
}
44+
45+
if (req.method === 'HEAD') {
46+
return res.status(200).end()
47+
}
48+
3149
// Remove any query string (?...) and/or fragment identifier (#...)
3250
const { pathname, searchParams } = new URL(req.originalUrl, 'https://docs.github.com')
3351

@@ -45,40 +63,35 @@ module.exports = async function renderPage (req, res, next) {
4563
const isRequestingJsonForDebugging = 'json' in req.query && process.env.NODE_ENV !== 'production'
4664

4765
if (isCacheable && !isRequestingJsonForDebugging) {
66+
// Stop processing if the connection was already dropped
67+
if (isConnectionDropped(req, res)) return
68+
4869
const cachedHtml = await pageCache.get(originalUrl)
4970
if (cachedHtml) {
71+
// Stop processing if the connection was already dropped
72+
if (isConnectionDropped(req, res)) return
73+
5074
console.log(`Serving from cached version of ${originalUrl}`)
5175
statsd.increment('page.sent_from_cache')
5276
return res.send(addCsrf(req, cachedHtml))
5377
}
5478
}
5579

56-
// render a 404 page
57-
if (!page) {
58-
if (process.env.NODE_ENV !== 'test' && req.context.redirectNotFound) {
59-
console.error(`\nTried to redirect to ${req.context.redirectNotFound}, but that page was not found.\n`)
60-
}
61-
return res.status(404).send(
62-
addCsrf(
63-
req,
64-
await liquid.parseAndRender(layouts['error-404'], req.context)
65-
)
66-
)
67-
}
68-
69-
if (req.method === 'HEAD') {
70-
return res.status(200).end()
71-
}
72-
7380
// add page context
7481
const context = Object.assign({}, req.context, { page })
7582

7683
// collect URLs for variants of this page in all languages
7784
context.page.languageVariants = Page.getLanguageVariants(req.path)
7885

86+
// Stop processing if the connection was already dropped
87+
if (isConnectionDropped(req, res)) return
88+
7989
// render page
8090
context.renderedPage = await page.render(context)
8191

92+
// Stop processing if the connection was already dropped
93+
if (isConnectionDropped(req, res)) return
94+
8295
// get mini TOC items on articles
8396
if (page.showMiniToc) {
8497
context.miniTocItems = getMiniTocItems(context.renderedPage, page.miniTocMaxHeadingLevel)

middleware/timeout.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
const timeout = require('express-timeout-handler')
2+
3+
// Heroku router requests timeout after 30 seconds. We should stop them earlier!
4+
const maxRequestTimeout = parseInt(process.env.REQUEST_TIMEOUT, 10) || 25000
5+
6+
module.exports = timeout.handler({
7+
// Default timeout for all endpoints
8+
// To override for a given router/endpoint, use `require('express-timeout-handler').set(...)`
9+
timeout: maxRequestTimeout,
10+
11+
// IMPORTANT:
12+
// We cannot allow the middleware to disable the `res` object's methods like
13+
// it does by default if we want to use `next` in the `onTimeout` handler!
14+
disable: [],
15+
16+
onTimeout: function (req, res, next) {
17+
// Create a custom timeout error
18+
const timeoutError = new Error('Request timed out')
19+
timeoutError.statusCode = 503
20+
timeoutError.code = 'ETIMEDOUT'
21+
22+
// Pass the error to our Express error handler for consolidated processing
23+
return next(timeoutError)
24+
}
25+
26+
// Can also set an `onDelayedResponse` property IF AND ONLY IF you allow for disabling methods
27+
})

package-lock.json

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"dotenv": "^8.2.0",
4444
"express": "^4.17.1",
4545
"express-rate-limit": "^5.1.3",
46+
"express-timeout-handler": "^2.2.0",
4647
"flat": "^5.0.0",
4748
"github-slugger": "^1.2.1",
4849
"got": "^9.6.0",

0 commit comments

Comments
 (0)