Repository: nodejs/undici

Problem statement:
RetryAgent doesn't keep the event loop alive
## Bug Description

When using RetryAgent, Node exits while there are pending retries in flight. 

## Reproducible By

I've been playing around in a brand new node project with only `unidici` installed and trying to run the following script. This is mostly copy/pasted from the docs - I don't think I'm doing anything unorthodox here.

```js
import { request, Agent, RetryAgent } from "undici";

const resp = await request("http://httpbin.org/status/418", {
  dispatcher: new RetryAgent(new Agent(), {
    statusCodes: [418],
  }),
  headers: { "User-Agent": "teapot" },
});

console.log("status:", resp.statusCode);
console.log("headers:", resp.headers);
```

## Expected Behavior

I'd expect to get either an HTTP response or a retry error.

## Logs & Screenshots

```
$ node example.js; echo "exit code: $status"
Warning: Detected unsettled top-level await at file:///[redacted]/example.js:3
const resp = await request("http://httpbin.org/status/418", {
             ^



exit code: 13
```

### Additional context

```
$ node --version
v23.5.0
$ grep undici package.json
    "undici": "^7.2.0"
```


Hints:
Thanks for reporting! Would you like to send a Pull Request to address this issue? Remember to add unit tests.
> Thanks for reporting! Would you like to send a Pull Request to address this issue? Remember to add unit tests.

It's not obvious to me what's wrong, or I would have sent one already! From skimming the RetryAgent code I can't tell why Node would decide the task queue is empty.

Patch:
diff --git a/lib/handler/retry-handler.js b/lib/handler/retry-handler.js
--- a/lib/handler/retry-handler.js
+++ b/lib/handler/retry-handler.js
@@ -133,7 +133,7 @@ class RetryHandler {
         ? Math.min(retryAfterHeader, maxTimeout)
         : Math.min(minTimeout * timeoutFactor ** (counter - 1), maxTimeout)
 
-    setTimeout(() => cb(null), retryTimeout).unref()
+    setTimeout(() => cb(null), retryTimeout)
   }
 
   onResponseStart (controller, statusCode, headers, statusMessage) {


Test patch:
diff --git a/test/fixtures/interceptors/retry-event-loop.js b/test/fixtures/interceptors/retry-event-loop.js
new file mode 100644
--- /dev/null
+++ b/test/fixtures/interceptors/retry-event-loop.js
@@ -0,0 +1,33 @@
+'use strict'
+
+const { createServer } = require('node:http')
+const { once } = require('node:events')
+const {
+  Client,
+  interceptors: { retry }
+} = require('../../..')
+
+const server = createServer()
+
+server.on('request', (req, res) => {
+  res.writeHead(418, { 'Content-Type': 'text/plain' })
+  res.end('teapot')
+})
+
+server.listen(0)
+once(server, 'listening').then(() => {
+  const client = new Client(
+    `http://localhost:${server.address().port}`
+  ).compose(
+    retry({
+      maxTimeout: 1000,
+      maxRetries: 3,
+      statusCodes: [418]
+    })
+  )
+
+  return client.request({
+    method: 'GET',
+    path: '/'
+  })
+})
diff --git a/test/interceptors/retry.js b/test/interceptors/retry.js
--- a/test/interceptors/retry.js
+++ b/test/interceptors/retry.js
@@ -4,6 +4,7 @@ const { tspl } = require('@matteo.collina/tspl')
 const { test, after } = require('node:test')
 const { createServer } = require('node:http')
 const { once } = require('node:events')
+const { spawnSync } = require('node:child_process')
 
 const { Client, interceptors } = require('../..')
 const { retry, redirect, dns } = interceptors
@@ -559,3 +560,16 @@ test('should not error if request is not meant to be retried', async t => {
   t.equal(response.statusCode, 400)
   t.equal(await response.body.text(), 'Bad request')
 })
+
+test('#3975 - keep event loop ticking', async t => {
+  const suite = tspl(t, { plan: 3 })
+
+  const res = spawnSync('node', ['./test/fixtures/interceptors/retry-event-loop.js'], {
+    stdio: 'pipe'
+  })
+
+  const output = res.stderr.toString()
+  suite.ok(output.includes("code: 'UND_ERR_REQ_RETRY'"))
+  suite.ok(output.includes('RequestRetryError: Request failed'))
+  suite.ok(output.includes('statusCode: 418'))
+})