Skip to content

Commit

Permalink
feat(middleware): Introduce Timeout Middleware (#2615)
Browse files Browse the repository at this point in the history
* 0.1

* v0.2

* 1.0

* multi runtime

* adding duration

* fix

* interface

* fix

* only input number

* return a named function

* setting exception

* simply

* rename type

* adding typesVersions

* typo

* add JSDoc

* denoify
  • Loading branch information
watany-dev authored May 22, 2024
1 parent a8a5fb9 commit 8cb59e4
Show file tree
Hide file tree
Showing 6 changed files with 186 additions and 0 deletions.
1 change: 1 addition & 0 deletions deno_dist/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export { jwt } from './middleware/jwt/index.ts'
export * from './middleware/logger/index.ts'
export * from './middleware/method-override/index.ts'
export * from './middleware/powered-by/index.ts'
export * from './middleware/timeout/index.ts'
export * from './middleware/timing/index.ts'
export * from './middleware/pretty-json/index.ts'
export * from './middleware/secure-headers/index.ts'
Expand Down
53 changes: 53 additions & 0 deletions deno_dist/middleware/timeout/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type { Context } from '../../context.ts'
import { HTTPException } from '../../http-exception.ts'
import type { MiddlewareHandler } from '../../types.ts'

export type HTTPExceptionFunction = (context: Context) => HTTPException

const defaultTimeoutException = new HTTPException(504, {
message: 'Gateway Timeout',
})

/**
* Timeout middleware for Hono.
*
* @param {number} duration - The timeout duration in milliseconds.
* @param {HTTPExceptionFunction | HTTPException} [exception=defaultTimeoutException] - The exception to throw when the timeout occurs. Can be a function that returns an HTTPException or an HTTPException object.
* @returns {MiddlewareHandler} The middleware handler function.
*
* @example
* ```ts
* const app = new Hono()
*
* app.use(
* '/long-request',
* timeout(5000) // Set timeout to 5 seconds
* )
*
* app.get('/long-request', async (c) => {
* await someLongRunningFunction()
* return c.text('Completed within time limit')
* })
* ```
*/
export const timeout = (
duration: number,
exception: HTTPExceptionFunction | HTTPException = defaultTimeoutException
): MiddlewareHandler => {
return async function timeout(context, next) {
let timer: number | undefined
const timeoutPromise = new Promise<void>((_, reject) => {
timer = setTimeout(() => {
reject(typeof exception === 'function' ? exception(context) : exception)
}, duration) as unknown as number
})

try {
await Promise.race([next(), timeoutPromise])
} finally {
if (timer !== undefined) {
clearTimeout(timer)
}
}
}
}
8 changes: 8 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,11 @@
"import": "./dist/middleware/jwt/index.js",
"require": "./dist/cjs/middleware/jwt/index.js"
},
"./timeout": {
"types": "./dist/types/middleware/timeout/index.d.ts",
"import": "./dist/middleware/timeout/index.js",
"require": "./dist/cjs/middleware/timeout/index.js"
},
"./timing": {
"types": "./dist/types/middleware/timing/index.d.ts",
"import": "./dist/middleware/timing/index.js",
Expand Down Expand Up @@ -425,6 +430,9 @@
"jwt": [
"./dist/types/middleware/jwt"
],
"timeout": [
"./dist/types/middleware/timeout"
],
"timing": [
"./dist/types/middleware/timing"
],
Expand Down
1 change: 1 addition & 0 deletions src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export { jwt } from './middleware/jwt'
export * from './middleware/logger'
export * from './middleware/method-override'
export * from './middleware/powered-by'
export * from './middleware/timeout'
export * from './middleware/timing'
export * from './middleware/pretty-json'
export * from './middleware/secure-headers'
Expand Down
70 changes: 70 additions & 0 deletions src/middleware/timeout/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import type { Context } from '../../context'
import { Hono } from '../../hono'
import { HTTPException } from '../../http-exception'
import type { HTTPExceptionFunction } from '.'
import { timeout } from '.'

describe('Timeout API', () => {
const app = new Hono()

app.use('/slow-endpoint', timeout(1000))
app.use(
'/slow-endpoint/custom',
timeout(
1100,
() => new HTTPException(408, { message: 'Request timeout. Please try again later.' })
)
)
const exception500: HTTPExceptionFunction = (context: Context) =>
new HTTPException(500, { message: `Internal Server Error at ${context.req.path}` })
app.use('/slow-endpoint/error', timeout(1200, exception500))
app.use('/normal-endpoint', timeout(1000))

app.get('/slow-endpoint', async (c) => {
await new Promise((resolve) => setTimeout(resolve, 1100))
return c.text('This should not show up')
})

app.get('/slow-endpoint/custom', async (c) => {
await new Promise((resolve) => setTimeout(resolve, 1200))
return c.text('This should not show up')
})

app.get('/slow-endpoint/error', async (c) => {
await new Promise((resolve) => setTimeout(resolve, 1300))
return c.text('This should not show up')
})

app.get('/normal-endpoint', async (c) => {
await new Promise((resolve) => setTimeout(resolve, 900))
return c.text('This should not show up')
})

it('Should trigger default timeout exception', async () => {
const res = await app.request('http://localhost/slow-endpoint')
expect(res).not.toBeNull()
expect(res.status).toBe(504)
expect(await res.text()).toContain('Gateway Timeout')
})

it('Should apply custom exception with function', async () => {
const res = await app.request('http://localhost/slow-endpoint/custom')
expect(res).not.toBeNull()
expect(res.status).toBe(408)
expect(await res.text()).toContain('Request timeout. Please try again later.')
})

it('Error timeout with custom status code and message', async () => {
const res = await app.request('http://localhost/slow-endpoint/error')
expect(res).not.toBeNull()
expect(res.status).toBe(500)
expect(await res.text()).toContain('Internal Server Error at /slow-endpoint/error')
})

it('No Timeout should pass', async () => {
const res = await app.request('http://localhost/normal-endpoint')
expect(res).not.toBeNull()
expect(res.status).toBe(200)
expect(await res.text()).toContain('This should not show up')
})
})
53 changes: 53 additions & 0 deletions src/middleware/timeout/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type { Context } from '../../context'
import { HTTPException } from '../../http-exception'
import type { MiddlewareHandler } from '../../types'

export type HTTPExceptionFunction = (context: Context) => HTTPException

const defaultTimeoutException = new HTTPException(504, {
message: 'Gateway Timeout',
})

/**
* Timeout middleware for Hono.
*
* @param {number} duration - The timeout duration in milliseconds.
* @param {HTTPExceptionFunction | HTTPException} [exception=defaultTimeoutException] - The exception to throw when the timeout occurs. Can be a function that returns an HTTPException or an HTTPException object.
* @returns {MiddlewareHandler} The middleware handler function.
*
* @example
* ```ts
* const app = new Hono()
*
* app.use(
* '/long-request',
* timeout(5000) // Set timeout to 5 seconds
* )
*
* app.get('/long-request', async (c) => {
* await someLongRunningFunction()
* return c.text('Completed within time limit')
* })
* ```
*/
export const timeout = (
duration: number,
exception: HTTPExceptionFunction | HTTPException = defaultTimeoutException
): MiddlewareHandler => {
return async function timeout(context, next) {
let timer: number | undefined
const timeoutPromise = new Promise<void>((_, reject) => {
timer = setTimeout(() => {
reject(typeof exception === 'function' ? exception(context) : exception)
}, duration) as unknown as number
})

try {
await Promise.race([next(), timeoutPromise])
} finally {
if (timer !== undefined) {
clearTimeout(timer)
}
}
}
}

0 comments on commit 8cb59e4

Please sign in to comment.