How @openai/agents made our Next.js server crash

Dec 21, 2025 | 7 minute read

A few weeks ago, we started noticing random crashes in our main web service. Right before the server went down, we'd see aborted and ECONNRESET errors logged as unhandled rejections.

Aborted requests happen all the time. But why was one taking down the entire process?

We eventually traced the process exit to a global unhandledRejection listener in @openai/agents 0.0.5. Inside that listener was a process.exit(1).

Getting from the request error to that line took a bit of digging.

Web Server Crash

What was the crash?

To start with, we have an MCP route on our Next.js server: /api/v3/mcp/<server_id>/mcp. It exposes our MCP server using vercel/mcp-handler. This was the route where we were seeing the error:

⨯ unhandledRejection: [Error: aborted] { code: 'ECONNRESET' }

At first glance, this seemed normal-ish. A client can disconnect because of a timeout, a network drop, or simply because it no longer needs the response.

The weird part was: why was this surfacing as an unhandled rejection, and why was the process exiting afterward?

Can unhandledRejection crash the process?

Yes, but it depends on how Node is configured and which process-level handlers are registered.

In Node's default --unhandled-rejections=throw mode, an unhandled rejection is raised as an uncaught exception if there is no unhandledRejection listener. Without an uncaughtException handler, the process exits.

Once listeners are involved, you also need to look at what they do. A listener can explicitly call process.exit(1) regardless of the default behavior. See the Node documentation for more details.

Reproducing this locally

After some trial and error, we tried sending a request to the route and aborting it almost immediately.

Here's the client script we used against our local server:

const controller = new AbortController();
 
// Abort almost immediately
setTimeout(() => {
  controller.abort();
}, 15);
 
try {
  await fetch("http://localhost:3000/api/v3/mcp/<server_id>/mcp", {
    signal: controller.signal,
  });
} catch (err) {
  if (err.name === "AbortError") {
    console.log("Request aborted before it could complete");
  } else {
    console.error("Fetch failed:", err);
  }
}

This reproduced the crash against our local MCP route. The 15 ms delay depends on timing, though: abort too early and the request might never reach the server. You'll need the server and its dependencies running to reproduce the same behavior.

Looking into the stack trace

Our logs showed the error, but they didn't point us to the application code involved. So, we added our own listener to gather more context.

The useful part was printing the rejected error itself:

process.on("unhandledRejection", (reason) => {
  console.error(
    "Manual unhandledRejection:",
    reason instanceof Error ? reason.stack : reason,
  );
});

This was just for debugging: adding an unhandledRejection listener changes Node's default rejection behavior. For uncaught exceptions, uncaughtExceptionMonitor lets you log them without overriding Node's default exit behavior.

At last, we could see a stack trace:

Error: aborted
  at abortIncoming (node:_http_server:809:17)
  at socketOnClose (node:_http_server:803:3)
  at Socket.emit (node:events:530:35)
  at Socket.emit (node:domain:489:12)
  at TCP.<anonymous> (node:net:351:12)
  at TCP.callbackTrampoline (node:internal/async_hooks:130:17)

This pointed to the client connection closing and Node creating the abort error. But it still didn't show which application promise had gone unhandled, or what was making the process exit.

💡 Why doesn't the stack point to our code?

One thing that makes this harder to debug is how Web Streams propagate errors.

When a ReadableStream errors, the Streams Standard stores the error on the stream as [[storedError]]. A later read can reject with that same error object. Its stack can point to where the error was created, rather than where your code called await reader.read().

There's a Node issue about missing read call sites with a similar example. The author provides a workaround:

await reader.read().catch((e) => {
  throw new AggregateError([e]);
});

It's an annoying workaround, but it captures a new stack at the read call site while retaining the original error inside the AggregateError.

Debugging with the --inspect flag

At this point, we stopped guessing and used a debugger.

We launched our Next.js standalone server with:

NODE_OPTIONS="--inspect" node .next/standalone/apps/apollo/server.js

The culprit

With the debugger running, we found the reason the process was exiting. We were using @openai/agents 0.0.5, whose tracing provider registered this global handler when it was constructed:

process.on('unhandledRejection', async (reason, promise) => {
  logger.error('Unhandled rejection', reason, promise);
  await cleanup();
  process.exit(1);
});

You can see it in the 0.0.5 tracing provider source.

That explained why the server died. The request abort had produced an unhandled rejection, and this handler turned it into a process exit.

Finding the handler answered the process-exit question. It didn't, by itself, identify which promise had gone unhandled. That still needed tracing back to the request path.

"But Next.js already listens to unhandledRejection" — why didn't that save us?

Because process.on(...) doesn't override other listeners. It adds another one.

In our setup, the sequence was:

  1. A request was aborted, and a promise rejected without a handler.
  2. Node emitted unhandledRejection.
  3. Next.js logged the error.
  4. The handler from @openai/agents ran its cleanup and called process.exit(1).
  5. The process exited.

Next.js logging the error couldn't prevent another listener from explicitly exiting the process.

What we changed

The fix was to upgrade @openai/agents from 0.0.5 to 0.3.4 and update the lockfile. The dependency change was:

- "@openai/agents": "^0.0.5",
+ "@openai/agents": "^0.3.4",

The lockfile moved from 0.0.5 to 0.3.4 as well, so this changed the installed version, not just the range in package.json.

By 0.3.4, the rejection handler had an extra check after cleanup:

process.on('unhandledRejection', async (reason, promise) => {
  logger.error('Unhandled rejection', reason, promise);
  await cleanup();
  if (!hasOtherListenersForEvents('unhandledRejection')) {
    process.exit(1);
  }
});

The helper checks whether process.listeners(event).length > 1. The SDK's own listener is already in that list, so another listener means the host application has also registered a handler. Both the updated handler and the helper are in the 0.3.4 source.

In our setup, Next.js had its own listener. That meant the SDK would skip this process.exit(1) after cleanup. It still registered a global handler, but it now checked for the host's handler before taking that exit path.

That addressed the process-exit side of the problem. The request-related rejection still needed handling at its source. Body parsing, stream reads, and any work started for the request need to handle cancellation and clean up afterward.

That doesn't mean ignoring every ECONNRESET. The same code can describe other connection failures. Check whether it belongs to a request you know was cancelled, and keep unexpected failures visible.

Is it okay for a framework/library to call process.exit(1) on my behalf?

In my opinion, almost never, especially in a dependency that can run inside an existing web server.

If you're building a top-level CLI or a single-purpose worker and you control the runtime, failing fast can make sense. You can decide how to log the failure, clean up, and let a supervisor restart the process.

A reusable library doesn't have that context. It doesn't know whether the rejection came from its own code, whether the host framework already handles errors, or how the application is supposed to shut down.

The global part matters here: this handler sees unhandled rejections from across the process. It can end up terminating the server because of a request path unrelated to the library's work.

If a library offers this behavior, I think it should be opt-in and clearly documented. A helper like installFatalHandlers() would let the application decide whether to enable it.

Takeaways

  • A client disconnect can be routine. Letting its rejection go unhandled is still something to fix.
  • An error's stack can show where it was created without showing where your code encountered it.
  • Global rejection handlers can affect the entire application, even when a dependency installs them for its own purposes.
  • In our case, upgrading @openai/agents from 0.0.5 to 0.3.4 addressed the forced exit after cleanup when Next.js already had a rejection handler.

The useful clue here was that ECONNRESET explained the request failure, but it didn't explain the process exit. If you're seeing the same pattern, check what's listening for unhandledRejection. In our case, that was where an aborted request turned into the whole server going away.