Skip to content

Unhandled rejection ClientDestroyedError when using Agent with clientTtl #4806

@hexchain

Description

@hexchain

Bug Description

When using an Agent with the clientTtl option, under certain conditions, an uncatchable ClientDestroyedError will be thrown.

Reproducible By

const undici = require('undici');
const tp = require('node:timers/promises');

async function main() {
  const clientTtl = 1000;

  const agent = new undici.Agent({ clientTtl });

  console.log('request 1')
  const resp = await undici.request("https://example.com", {
    dispatcher: agent
  });
  console.log(resp.statusCode);
  // do not consume or destroy the response body here

  await tp.setTimeout(clientTtl * 2);

  console.log('request 2')
  const resp1 = await undici.request("https://example.com", {
    dispatcher: agent
  });
  console.log(resp1.statusCode);
}

process.on('unhandledRejection', (e) => {
  console.log('unhandled rejection', e)
})

void main();

Output:

request 1
200
request 2
unhandled rejection ClientDestroyedError: The client is destroyed
    at Client.close (/tmp/test/node_modules/undici/lib/dispatcher/dispatcher-base.js:52:19)
    at /tmp/test/node_modules/undici/lib/dispatcher/dispatcher-base.js:41:14
    at new Promise (<anonymous>)
    at Client.close (/tmp/test/node_modules/undici/lib/dispatcher/dispatcher-base.js:40:14)
    at [close] (/tmp/test/node_modules/undici/lib/dispatcher/pool-base.js:124:41)
    at Pool.close (/tmp/test/node_modules/undici/lib/dispatcher/dispatcher-base.js:79:17)
    at /tmp/test/node_modules/undici/lib/dispatcher/dispatcher-base.js:41:14
    at new Promise (<anonymous>)
    at Pool.close (/tmp/test/node_modules/undici/lib/dispatcher/dispatcher-base.js:40:14)
    at closeClientIfUnused (/tmp/test/node_modules/undici/lib/dispatcher/agent.js:95:31) {
  code: 'UND_ERR_DESTROYED'
}
200

Expected Behavior

There should not be any error.

Environment

Node v24.13.0
Undici 7.20.0

Additional context

  • In fix: memory leak in Agent #4425 I made the change to close the client (Pool by default) when it's no longer used. However, the clientTtl option isn't considered, which means the pool will always be destroyed when it stops having an active connection.
  • In Pool.[kGetDispatcher], the client is removed from the Pool if it has been alive for too long. In PoolBase.[kRemoveClient], the client is only removed from the pool's client list in the callback. If PoolBase.[kClose] gets called after the client is destroyed but before the callback happens, the client will be closed again, triggering this issue.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions