Repository: fastify/fastify-reply-from
Pull request #414: feat: add specific timeout for request
Author: login: leonied7
Created: 2025-03-26T15:57:11Z | Merged: 2025-03-28T22:29:05Z

<!--
Thank you for your pull request. Please provide a description above and review
the requirements below.

Bug fixes and new features should include tests and possibly benchmarks.

Contributors guide: https://github.com/fastify/fastify/blob/main/CONTRIBUTING.md

By making a contribution to this project, I certify that:

* (a) The contribution was created in whole or in part by me and I
  have the right to submit it under the open source license
  indicated in the file; or

* (b) The contribution is based upon previous work that, to the best
  of my knowledge, is covered under an appropriate open source
  license and I have the right under that license to submit that
  work with modifications, whether created in whole or in part
  by me, under the same open source license (unless I am
  permitted to submit under a different license), as indicated
  in the file; or

* (c) The contribution was provided directly to me by some other
  person who certified (a), (b) or (c) and I have not modified
  it.

* (d) I understand and agree that this project and the contribution
  are public and that a record of the contribution (including all
  personal information I submit with it, including my sign-off) is
  maintained indefinitely and may be redistributed consistent with
  this project or the open source license(s) involved.
-->
Add a specific timeout for the request, which overwrites the default timeout.

#### Checklist

- [x] run `npm run test` and `npm run benchmark`
- [x] tests and/or benchmarks are included
- [x] documentation is changed or added
- [x] commit message and code follows the [Developer's Certification of Origin](https://github.com/fastify/.github/blob/main/CONTRIBUTING.md#developers-certificate-of-origin-11)
      and the [Code of conduct](https://github.com/fastify/.github/blob/main/CODE_OF_CONDUCT.md)



Diff:
diff --git a/README.md b/README.md
index ef542bf6..83605cad 100644
--- a/README.md
+++ b/README.md
@@ -477,6 +477,10 @@ By default: 0 (disabled)
 Override the `'Content-Type'` header of the forwarded request, if we are
 already overriding the [`body`](#body).
 
+#### `timeout`
+
+Set a specific timeout for the request. Override options `http.requestOptions.timeout`, `http2.requestOptions.timeout`, `undici.headersTimeout` and `undici.bodyTimeout` from the plugin config.
+
 ### Combining with [@fastify/formbody](https://github.com/fastify/fastify-formbody)
 
 `formbody` expects the body to be returned as a string and not an object.
diff --git a/index.js b/index.js
index 7d66fcae..75a6a66b 100644
--- a/index.js
+++ b/index.js
@@ -55,6 +55,7 @@ const fastifyReplyFrom = fp(function from (fastify, opts, next) {
     opts = opts || {}
     const req = this.request.raw
     const method = opts.method || req.method
+    const timeout = opts.timeout
     const onResponse = opts.onResponse
     const rewriteHeaders = opts.rewriteHeaders || headersNoOp
     const rewriteRequestHeaders = opts.rewriteRequestHeaders || requestHeadersNoOp
@@ -169,7 +170,7 @@ const fastifyReplyFrom = fp(function from (fastify, opts, next) {
       requestImpl = createRequestRetry(request, this, getDefaultDelay)
     }
 
-    requestImpl({ method, url, qs, headers: requestHeaders, body }, (err, res) => {
+    requestImpl({ method, url, qs, headers: requestHeaders, body, timeout }, (err, res) => {
       if (err) {
         this.request.log.warn(err, 'response errored')
         if (!this.sent) {
diff --git a/lib/request.js b/lib/request.js
index 882d1950..2afb8f78 100644
--- a/lib/request.js
+++ b/lib/request.js
@@ -120,7 +120,8 @@ function buildRequest (opts) {
       hostname: opts.url.hostname,
       headers: opts.headers,
       agent: agents[opts.url.protocol.replace(/^unix:/, '')],
-      ...httpOpts.requestOptions
+      ...httpOpts.requestOptions,
+      timeout: opts.timeout ?? httpOpts.requestOptions.timeout
     })
     req.on('error', done)
     req.on('response', res => {
@@ -146,8 +147,8 @@ function buildRequest (opts) {
       method: opts.method,
       headers: Object.assign({}, opts.headers),
       body: opts.body,
-      headersTimeout: undiciOpts.headersTimeout,
-      bodyTimeout: undiciOpts.bodyTimeout
+      headersTimeout: opts.timeout ?? undiciOpts.headersTimeout,
+      bodyTimeout: opts.timeout ?? undiciOpts.bodyTimeout
     }
 
     let pool
@@ -212,7 +213,7 @@ function buildRequest (opts) {
     if (!isGet && !isDelete) {
       end(req, opts.body, done)
     }
-    req.setTimeout(http2Opts.requestTimeout, () => {
+    req.setTimeout(opts.timeout ?? http2Opts.requestTimeout, () => {
       const err = new Http2RequestTimeoutError()
       req.close(http2.constants.NGHTTP2_CANCEL)
       done(err)
diff --git a/test/http-timeout.test.js b/test/http-timeout.test.js
index 262cd1ed..8e6cb445 100644
--- a/test/http-timeout.test.js
+++ b/test/http-timeout.test.js
@@ -6,19 +6,19 @@ const From = require('..')
 const got = require('got')
 const FakeTimers = require('@sinonjs/fake-timers')
 
-const clock = FakeTimers.createClock()
-
 test('http request timeout', async (t) => {
+  const clock = FakeTimers.createClock()
   const target = Fastify()
   t.teardown(target.close.bind(target))
 
   target.get('/', (_request, reply) => {
     t.pass('request arrives')
 
-    clock.setTimeout(() => {
+    setTimeout(() => {
       reply.status(200).send('hello world')
-      t.end()
     }, 200)
+
+    clock.tick(200)
   })
 
   await target.listen({ port: 0 })
@@ -45,7 +45,60 @@ test('http request timeout', async (t) => {
       error: 'Gateway Timeout',
       message: 'Gateway Timeout'
     })
+    return
+  }
+
+  t.fail()
+})
+
+test('http request with specific timeout', async (t) => {
+  const clock = FakeTimers.createClock()
+  const target = Fastify()
+  t.teardown(target.close.bind(target))
+
+  target.get('/', (_request, reply) => {
+    t.pass('request arrives')
+
+    setTimeout(() => {
+      reply.status(200).send('hello world')
+    }, 200)
+
     clock.tick(200)
+  })
+
+  await target.listen({ port: 0 })
+
+  const instance = Fastify()
+  t.teardown(instance.close.bind(instance))
+
+  instance.register(From, { http: { requestOptions: { timeout: 100 } } })
+
+  instance.get('/success', (_request, reply) => {
+    reply.from(`http://localhost:${target.server.address().port}/`, {
+      timeout: 300
+    })
+  })
+  instance.get('/fail', (_request, reply) => {
+    reply.from(`http://localhost:${target.server.address().port}/`, {
+      timeout: 50
+    })
+  })
+
+  await instance.listen({ port: 0 })
+  const { statusCode } = await got.get(`http://localhost:${instance.server.address().port}/success`, { retry: 0 })
+  t.equal(statusCode, 200)
+
+  try {
+    await got.get(`http://localhost:${instance.server.address().port}/fail`, { retry: 0 })
+  } catch (err) {
+    t.equal(err.response.statusCode, 504)
+    t.match(err.response.headers['content-type'], /application\/json/)
+    t.same(JSON.parse(err.response.body), {
+      statusCode: 504,
+      code: 'FST_REPLY_FROM_GATEWAY_TIMEOUT',
+      error: 'Gateway Timeout',
+      message: 'Gateway Timeout'
+    })
     return
   }
 
diff --git a/test/http2-timeout.test.js b/test/http2-timeout.test.js
index e07eab03..fe427770 100644
--- a/test/http2-timeout.test.js
+++ b/test/http2-timeout.test.js
@@ -4,6 +4,7 @@ const { test } = require('tap')
 const Fastify = require('fastify')
 const From = require('..')
 const got = require('got')
+const FakeTimers = require('@sinonjs/fake-timers')
 
 test('http2 request timeout', async (t) => {
   const target = Fastify({ http2: true, sessionTimeout: 0 })
@@ -49,6 +50,63 @@ test('http2 request timeout', async (t) => {
   t.fail()
 })
 
+test('http2 request with specific timeout', async (t) => {
+  const clock = FakeTimers.createClock()
+  const target = Fastify({ http2: true })
+  t.teardown(target.close.bind(target))
+
+  target.get('/', (_request, reply) => {
+    t.pass('request arrives')
+
+    setTimeout(() => {
+      reply.status(200).send('hello world')
+    }, 200)
+
+    clock.tick(200)
+  })
+
+  await target.listen({ port: 0 })
+
+  const instance = Fastify()
+  t.teardown(instance.close.bind(instance))
+
+  instance.register(From, {
+    base: `http://localhost:${target.server.address().port}`,
+    http2: { requestTimeout: 100, sessionTimeout: 6000 }
+  })
+
+  instance.get('/success', (_request, reply) => {
+    reply.from(`http://localhost:${target.server.address().port}/`, {
+      timeout: 300
+    })
+  })
+  instance.get('/fail', (_request, reply) => {
+    reply.from(`http://localhost:${target.server.address().port}/`, {
+      timeout: 50
+    })
+  })
+
+  await instance.listen({ port: 0 })
+  const { statusCode } = await got.get(`http://localhost:${instance.server.address().port}/success`, { retry: 0 })
+  t.equal(statusCode, 200)
+
+  try {
+    await got.get(`http://localhost:${instance.server.address().port}/fail`, { retry: 0 })
+  } catch (err) {
+    t.equal(err.response.statusCode, 504)
+    t.match(err.response.headers['content-type'], /application\/json/)
+    t.same(JSON.parse(err.response.body), {
+      statusCode: 504,
+      code: 'FST_REPLY_FROM_GATEWAY_TIMEOUT',
+      error: 'Gateway Timeout',
+      message: 'Gateway Timeout'
+    })
+    return
+  }
+
+  t.fail()
+})
+
 test('http2 session timeout', async (t) => {
   const target = Fastify({ http2: true, sessionTimeout: 0 })
   t.teardown(target.close.bind(target))
diff --git a/test/undici-timeout.test.js b/test/undici-timeout.test.js
index 9b1946c2..966eb84a 100644
--- a/test/undici-timeout.test.js
+++ b/test/undici-timeout.test.js
@@ -1,24 +1,24 @@
 'use strict'
 
-const t = require('tap')
+const { test } = require('tap')
 const Fastify = require('fastify')
 const From = require('..')
 const got = require('got')
 const FakeTimers = require('@sinonjs/fake-timers')
 
-const clock = FakeTimers.createClock()
-
-t.test('undici request timeout', async (t) => {
+test('undici request timeout', async (t) => {
+  const clock = FakeTimers.createClock()
   const target = Fastify()
   t.teardown(target.close.bind(target))
 
   target.get('/', (_request, reply) => {
     t.pass('request arrives')
 
-    clock.setTimeout(() => {
+    setTimeout(() => {
       reply.status(200).send('hello world')
-      t.end()
     }, 1000)
+
+    clock.tick(1000)
   })
 
   await target.listen({ port: 0 })
@@ -50,7 +50,66 @@ t.test('undici request timeout', async (t) => {
       error: 'Gateway Timeout',
       message: 'Gateway Timeout'
     })
+    return
+  }
+
+  t.fail()
+})
+
+test('undici request with specific timeout', async (t) => {
+  const clock = FakeTimers.createClock()
+  const target = Fastify()
+  t.teardown(target.close.bind(target))
+
+  target.get('/', (_request, reply) => {
+    t.pass('request arrives')
+
+    setTimeout(() => {
+      reply.status(200).send('hello world')
+    }, 1000)
+
     clock.tick(1000)
+  })
+
+  await target.listen({ port: 0 })
+
+  const instance = Fastify()
+  t.teardown(instance.close.bind(instance))
+
+  instance.register(From, {
+    base: `http://localhost:${target.server.address().port}`,
+    undici: {
+      headersTimeout: 100,
+    }
+  })
+
+  instance.get('/success', (_request, reply) => {
+    reply.from('/', {
+      timeout: 1000
+    })
+  })
+  instance.get('/fail', (_request, reply) => {
+    reply.from('/', {
+      timeout: 50
+    })
+  })
+
+  await instance.listen({ port: 0 })
+
+  const { statusCode } = await got.get(`http://localhost:${instance.server.address().port}/success`, { retry: 0 })
+  t.equal(statusCode, 200)
+
+  try {
+    await got.get(`http://localhost:${instance.server.address().port}/fail`, { retry: 0 })
+  } catch (err) {
+    t.equal(err.response.statusCode, 504)
+    t.match(err.response.headers['content-type'], /application\/json/)
+    t.same(JSON.parse(err.response.body), {
+      statusCode: 504,
+      code: 'FST_REPLY_FROM_GATEWAY_TIMEOUT',
+      error: 'Gateway Timeout',
+      message: 'Gateway Timeout'
+    })
     return
   }
 
diff --git a/types/index.d.ts b/types/index.d.ts
index 30d2aaa3..9ad21de9 100644
--- a/types/index.d.ts
+++ b/types/index.d.ts
@@ -83,6 +83,7 @@ declare namespace fastifyReplyFrom {
       base: string
     ) => string;
     method?: HTTPMethods;
+    timeout?: number;
   }
 
   interface Http2Options {
diff --git a/types/index.test-d.ts b/types/index.test-d.ts
index 975c29a6..de66f4bf 100644
--- a/types/index.test-d.ts
+++ b/types/index.test-d.ts
@@ -70,6 +70,7 @@ async function main () {
   })
   server.get('/v3', (_request, reply) => {
     reply.from('/v3', {
+      timeout: 1000,
       body: { hello: 'world' },
       rewriteRequestHeaders (req, headers) {
         expectType<FastifyRequest<RequestGenericInterface, RawServerBase>>(req)
@@ -102,7 +103,7 @@ async function main () {
   instance.get('/http2', (_request, reply) => {
     reply.from('/', {
       method: 'POST',
-      // eslint-disable-next-line n/handle-callback-err -- Not a real request, not handling errors
+
       retryDelay: ({ req, res, getDefaultDelay }) => {
         const defaultDelay = getDefaultDelay()
         if (defaultDelay) return defaultDelay


Reviews:
user: login: gurgunday
body: Hey!

Thanks for the PR! Please a test for the type, we use `tsd`.

Also, documentation needs to be updated
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-26T18:56:47Z
user: login: mcollina
body: lgtm
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-27T09:56:20Z
user: login: climba03003
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-03-28T02:54:14Z
user: login: leonied7
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T06:39:05Z
user: login: gurgunday
body: lgtm
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-28T20:22:21Z
user: login: climba03003
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-28T21:23:13Z

Review comments:
diff_hunk: @@ -120,7 +120,8 @@ function buildRequest (opts) {
       hostname: opts.url.hostname,
       headers: opts.headers,
       agent: agents[opts.url.protocol.replace(/^unix:/, '')],
-      ...httpOpts.requestOptions
+      ...httpOpts.requestOptions,
+      timeout: opts.timeout || httpOpts.requestOptions.timeout
path: lib/request.js
user: login: climba03003
body: Instead of `||`, using `??` is better in this case.
Since timeout can be `0`.
created_at: 2025-03-28T02:54:08Z
author_association: MEMBER
diff_hunk: @@ -120,7 +120,8 @@ function buildRequest (opts) {
       hostname: opts.url.hostname,
       headers: opts.headers,
       agent: agents[opts.url.protocol.replace(/^unix:/, '')],
-      ...httpOpts.requestOptions
+      ...httpOpts.requestOptions,
+      timeout: opts.timeout || httpOpts.requestOptions.timeout
path: lib/request.js
user: login: leonied7
body: @climba03003 thanks for the review! 

Good point
created_at: 2025-03-28T06:39:05Z
author_association: CONTRIBUTOR

PR comments:
user: login: leonied7
created_at: 2025-03-27T05:13:19Z
body: @gurgunday Hey!

Thanks for the review!

I added a test for the type and updated the documentation.
author_association: CONTRIBUTOR
user: login: leonied7
created_at: 2025-03-27T11:04:55Z
body: Nice, thank you @mcollina. I don't have rights to merge this so I leave it up to you.
author_association: CONTRIBUTOR
user: login: mcollina
created_at: 2025-03-27T15:44:48Z
body: letàs wait for @gurgunday 
author_association: MEMBER
user: login: github-actions[bot]
created_at: 2026-05-01T03:47:29Z
body: This pull request has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs.
author_association: CONTRIBUTOR