1
0
mirror of synced 2025-12-22 19:34:15 -05:00

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
This commit is contained in:
James M. Greene
2021-03-09 13:14:02 -06:00
committed by GitHub
parent 1ee4d3f670
commit fd7d0eeb1a
8 changed files with 126 additions and 29 deletions

15
middleware/abort.js Normal file
View File

@@ -0,0 +1,15 @@
module.exports = function (req, res, next) {
// If the client aborts the connection, send an error
req.once('aborted', () => {
// NOTE: Node.js will also automatically set `req.aborted = true`
const abortError = new Error('Client closed request')
abortError.statusCode = 499
abortError.code = 'ECONNRESET'
// Pass the error to the Express error handler
return next(abortError)
})
return next()
}

View File

@@ -0,0 +1,18 @@
function isConnectionDropped (req, res) {
// Have the flags been set for:
// - a global request timeout (via the express-timeout-handler middleware)?
// - an aborted request connection (via Node.js core's HTTP IncomingMessage)?
return Boolean(res.globalTimeout || req.aborted)
}
function haltOnDroppedConnection (req, res, next) {
// Only proceed if the flag has not been set for the express-timeout-handler middleware
if (!isConnectionDropped(req, res)) {
return next()
}
}
// Export this logic, too
haltOnDroppedConnection.isConnectionDropped = isConnectionDropped
module.exports = haltOnDroppedConnection

View File

@@ -6,7 +6,9 @@ const loadSiteData = require('../lib/site-data')
function shouldLogException (error) { function shouldLogException (error) {
const IGNORED_ERRORS = [ const IGNORED_ERRORS = [
// avoid sending CSRF token errors (from bad-actor POST requests) // avoid sending CSRF token errors (from bad-actor POST requests)
'EBADCSRFTOKEN' 'EBADCSRFTOKEN',
// Client connected aborted
'ECONNRESET'
] ]
if (IGNORED_ERRORS.includes(error.code)) { if (IGNORED_ERRORS.includes(error.code)) {
@@ -26,8 +28,8 @@ async function logException (error, req) {
} }
module.exports = async function handleError (error, req, res, next) { module.exports = async function handleError (error, req, res, next) {
// If the headers have already been sent... // If the headers have already been sent or the request was aborted...
if (res.headersSent) { if (res.headersSent || req.aborted) {
// Report to Failbot // Report to Failbot
await logException(error, req) await logException(error, req)

View File

@@ -1,5 +1,6 @@
const express = require('express') const express = require('express')
const instrument = require('../lib/instrument-middleware') const instrument = require('../lib/instrument-middleware')
const haltOnDroppedConnection = require('./halt-on-dropped-connection')
const isDevelopment = process.env.NODE_ENV === 'development' const isDevelopment = process.env.NODE_ENV === 'development'
@@ -11,6 +12,10 @@ const asyncMiddleware = fn =>
} }
module.exports = function (app) { module.exports = function (app) {
// *** Request connection management ***
app.use(require('./timeout'))
app.use(require('./abort'))
// *** Development tools *** // *** Development tools ***
app.use(require('morgan')('dev', { skip: (req, res) => !isDevelopment })) app.use(require('morgan')('dev', { skip: (req, res) => !isDevelopment }))
if (isDevelopment) app.use(require('./webpack')) if (isDevelopment) app.use(require('./webpack'))
@@ -60,6 +65,9 @@ module.exports = function (app) {
app.use(instrument('./find-page')) // Must come before archived-enterprise-versions, breadcrumbs, featured-links, products, render-page app.use(instrument('./find-page')) // Must come before archived-enterprise-versions, breadcrumbs, featured-links, products, render-page
app.use(instrument('./block-robots')) app.use(instrument('./block-robots'))
// Check for a dropped connection before proceeding
app.use(haltOnDroppedConnection)
// *** Rendering, 2xx responses *** // *** Rendering, 2xx responses ***
// I largely ordered these by use frequency // I largely ordered these by use frequency
app.use(instrument('./archived-enterprise-versions-assets')) // Must come before static/assets app.use(instrument('./archived-enterprise-versions-assets')) // Must come before static/assets
@@ -91,6 +99,9 @@ module.exports = function (app) {
app.use(instrument('./loaderio-verification')) app.use(instrument('./loaderio-verification'))
app.get('/_500', asyncMiddleware(instrument('./trigger-error'))) app.get('/_500', asyncMiddleware(instrument('./trigger-error')))
// Check for a dropped connection before proceeding (again)
app.use(haltOnDroppedConnection)
// *** Preparation for render-page *** // *** Preparation for render-page ***
app.use(asyncMiddleware(instrument('./contextualizers/enterprise-release-notes'))) app.use(asyncMiddleware(instrument('./contextualizers/enterprise-release-notes')))
app.use(instrument('./contextualizers/graphql')) app.use(instrument('./contextualizers/graphql'))
@@ -106,7 +117,12 @@ module.exports = function (app) {
// *** Headers for pages only *** // *** Headers for pages only ***
app.use(require('./set-fastly-cache-headers')) app.use(require('./set-fastly-cache-headers'))
// *** Rendering, must go last *** // Check for a dropped connection before proceeding (again)
app.use(haltOnDroppedConnection)
// *** Rendering, must go almost last ***
app.get('/*', asyncMiddleware(instrument('./render-page'))) app.get('/*', asyncMiddleware(instrument('./render-page')))
// *** Error handling, must go last ***
app.use(require('./handle-errors')) app.use(require('./handle-errors'))
} }

View File

@@ -6,6 +6,7 @@ const getMiniTocItems = require('../lib/get-mini-toc-items')
const Page = require('../lib/page') const Page = require('../lib/page')
const statsd = require('../lib/statsd') const statsd = require('../lib/statsd')
const RedisAccessor = require('../lib/redis-accessor') const RedisAccessor = require('../lib/redis-accessor')
const { isConnectionDropped } = require('./halt-on-dropped-connection')
const { HEROKU_RELEASE_VERSION } = process.env const { HEROKU_RELEASE_VERSION } = process.env
const pageCacheDatabaseNumber = 1 const pageCacheDatabaseNumber = 1
@@ -28,31 +29,6 @@ function addCsrf (req, text) {
module.exports = async function renderPage (req, res, next) { module.exports = async function renderPage (req, res, next) {
const page = req.context.page const page = req.context.page
// Remove any query string (?...) and/or fragment identifier (#...)
const { pathname, searchParams } = new URL(req.originalUrl, 'https://docs.github.com')
for (const queryKey in req.query) {
if (!cacheableQueries.includes(queryKey)) {
searchParams.delete(queryKey)
}
}
const originalUrl = pathname + ([...searchParams].length > 0 ? `?${searchParams}` : '')
// Serve from the cache if possible (skip during tests)
const isCacheable = !process.env.CI && process.env.NODE_ENV !== 'test' && req.method === 'GET'
// Is the request for JSON debugging info?
const isRequestingJsonForDebugging = 'json' in req.query && process.env.NODE_ENV !== 'production'
if (isCacheable && !isRequestingJsonForDebugging) {
const cachedHtml = await pageCache.get(originalUrl)
if (cachedHtml) {
console.log(`Serving from cached version of ${originalUrl}`)
statsd.increment('page.sent_from_cache')
return res.send(addCsrf(req, cachedHtml))
}
}
// render a 404 page // render a 404 page
if (!page) { if (!page) {
if (process.env.NODE_ENV !== 'test' && req.context.redirectNotFound) { if (process.env.NODE_ENV !== 'test' && req.context.redirectNotFound) {
@@ -70,15 +46,52 @@ module.exports = async function renderPage (req, res, next) {
return res.status(200).end() return res.status(200).end()
} }
// Remove any query string (?...) and/or fragment identifier (#...)
const { pathname, searchParams } = new URL(req.originalUrl, 'https://docs.github.com')
for (const queryKey in req.query) {
if (!cacheableQueries.includes(queryKey)) {
searchParams.delete(queryKey)
}
}
const originalUrl = pathname + ([...searchParams].length > 0 ? `?${searchParams}` : '')
// Serve from the cache if possible (skip during tests)
const isCacheable = !process.env.CI && process.env.NODE_ENV !== 'test' && req.method === 'GET'
// Is the request for JSON debugging info?
const isRequestingJsonForDebugging = 'json' in req.query && process.env.NODE_ENV !== 'production'
if (isCacheable && !isRequestingJsonForDebugging) {
// Stop processing if the connection was already dropped
if (isConnectionDropped(req, res)) return
const cachedHtml = await pageCache.get(originalUrl)
if (cachedHtml) {
// Stop processing if the connection was already dropped
if (isConnectionDropped(req, res)) return
console.log(`Serving from cached version of ${originalUrl}`)
statsd.increment('page.sent_from_cache')
return res.send(addCsrf(req, cachedHtml))
}
}
// add page context // add page context
const context = Object.assign({}, req.context, { page }) const context = Object.assign({}, req.context, { page })
// collect URLs for variants of this page in all languages // collect URLs for variants of this page in all languages
context.page.languageVariants = Page.getLanguageVariants(req.path) context.page.languageVariants = Page.getLanguageVariants(req.path)
// Stop processing if the connection was already dropped
if (isConnectionDropped(req, res)) return
// render page // render page
context.renderedPage = await page.render(context) context.renderedPage = await page.render(context)
// Stop processing if the connection was already dropped
if (isConnectionDropped(req, res)) return
// get mini TOC items on articles // get mini TOC items on articles
if (page.showMiniToc) { if (page.showMiniToc) {
context.miniTocItems = getMiniTocItems(context.renderedPage, page.miniTocMaxHeadingLevel) context.miniTocItems = getMiniTocItems(context.renderedPage, page.miniTocMaxHeadingLevel)

27
middleware/timeout.js Normal file
View File

@@ -0,0 +1,27 @@
const timeout = require('express-timeout-handler')
// Heroku router requests timeout after 30 seconds. We should stop them earlier!
const maxRequestTimeout = parseInt(process.env.REQUEST_TIMEOUT, 10) || 25000
module.exports = timeout.handler({
// Default timeout for all endpoints
// To override for a given router/endpoint, use `require('express-timeout-handler').set(...)`
timeout: maxRequestTimeout,
// IMPORTANT:
// We cannot allow the middleware to disable the `res` object's methods like
// it does by default if we want to use `next` in the `onTimeout` handler!
disable: [],
onTimeout: function (req, res, next) {
// Create a custom timeout error
const timeoutError = new Error('Request timed out')
timeoutError.statusCode = 503
timeoutError.code = 'ETIMEDOUT'
// Pass the error to our Express error handler for consolidated processing
return next(timeoutError)
}
// Can also set an `onDelayedResponse` property IF AND ONLY IF you allow for disabling methods
})

5
package-lock.json generated
View File

@@ -10914,6 +10914,11 @@
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-5.1.3.tgz", "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-5.1.3.tgz",
"integrity": "sha512-TINcxve5510pXj4n9/1AMupkj3iWxl3JuZaWhCdYDlZeoCPqweGZrxbrlqTCFb1CT5wli7s8e2SH/Qz2c9GorA==" "integrity": "sha512-TINcxve5510pXj4n9/1AMupkj3iWxl3JuZaWhCdYDlZeoCPqweGZrxbrlqTCFb1CT5wli7s8e2SH/Qz2c9GorA=="
}, },
"express-timeout-handler": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/express-timeout-handler/-/express-timeout-handler-2.2.0.tgz",
"integrity": "sha512-Ae9M32r3T+RmrY1MGwBvyVjnzVo1iD4s0YQHgvJqzPlT0d4qS84pr87LHyiUtnD7qDQLMYw6rl2fhsgH4TH8aQ=="
},
"ext": { "ext": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/ext/-/ext-1.4.0.tgz", "resolved": "https://registry.npmjs.org/ext/-/ext-1.4.0.tgz",

View File

@@ -43,6 +43,7 @@
"dotenv": "^8.2.0", "dotenv": "^8.2.0",
"express": "^4.17.1", "express": "^4.17.1",
"express-rate-limit": "^5.1.3", "express-rate-limit": "^5.1.3",
"express-timeout-handler": "^2.2.0",
"flat": "^5.0.0", "flat": "^5.0.0",
"github-slugger": "^1.2.1", "github-slugger": "^1.2.1",
"got": "^9.6.0", "got": "^9.6.0",