Skip to content

Commit c610b17

Browse files
authored
fix(nextjs): Prevent false API resolved without sending a response warning (#4139)
This prevents a false positive warning from nextjs which arises from the interaction of our API route wrapper `withSentry()` with nextjs's native API route handling. In dev, nextjs checks that API route handlers return a response to the client before they resolve, and it throws a warning[1] if this hasn't happened. Meanwhile, in `withSentry()`, we wrap the `res.end()` method to ensure that events are flushed before the request/response lifecycle finishes. As a result, there are cases where the handler resolves before the response is finished, while flushing is still in progress. This triggers the warning mentioned above, but it's a false alarm - the response may not have ended _yet_, but it will end. This suppresses the warning by temporarily marking the response finished, and then restoring it to unfinished before the original `res.end()` is called. The fix here is simple, but the async-i-ness of it all leads to some complex interactions in sequencing between the SDK, nextjs, and Node itself. I've tried to lay it out as clearly as I can in comments. Fixes #4007 Fixes #3852 [1] https://github.com/vercel/next.js/blob/e1464ae5a5061ae83ad015018d4afe41f91978b6/packages/next/server/api-utils.ts#L106-L118
1 parent 23a79ab commit c610b17

File tree

1 file changed

+66
-1
lines changed

1 file changed

+66
-1
lines changed

packages/nextjs/src/utils/withSentry.ts

+66-1
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,57 @@ export const withSentry = (origHandler: NextApiHandler): WrappedNextApiHandler =
7474
}
7575

7676
try {
77-
return await origHandler(req, res);
77+
const handlerResult = await origHandler(req, res);
78+
79+
// Temporarily mark the response as finished, as a hack to get nextjs to not complain that we're coming back
80+
// from the handler successfully without `res.end()` having completed its work. This is necessary (and we know
81+
// we can do it safely) for a few reasons:
82+
//
83+
// - Normally, `res.end()` is sync and completes before the request handler returns, as part of the handler
84+
// sending data back to the client. As soon as the handler is done, nextjs checks to make sure that the
85+
// response is indeed finished. (`res.end()` signals this by setting `res.finished` to `true`.) If it isn't,
86+
// nextjs complains. ("Warning: API resolved without sending a response for <url>.")
87+
//
88+
// - In order to prevent the lambda running the route handler from shutting down before we can send events to
89+
// Sentry, we monkeypatch `res.end()` so that we can call `flush()`, wait for it to finish, and only then
90+
// allow the response to be marked complete. This turns the normally-sync `res.end()` into an async function,
91+
// which isn't awaited because it's assumed to still be sync. As a result, nextjs runs the aforementioned
92+
// check before the now-async `res.end()` has had a chance to set `res.finished = false`, and therefore thinks
93+
// there's a problem when there's not.
94+
//
95+
// - In order to trick nextjs into not complaining, we can set `res.finished` to `true` before exiting the
96+
// handler. If we do that, though, `res.end()` gets mad because it thinks *it* should be the one to get to
97+
// mark the response complete. We therefore need to flip it back to `false` 1) after nextjs's check but 2)
98+
// before the original `res.end()` is called.
99+
//
100+
// - The second part is easy - we control when the original `res.end()` is called, so we can do the flipping
101+
// right beforehand and `res.end()` will be none the wiser.
102+
//
103+
// - The first part isn't as obvious. How do we know we won't end up with a race condition, such that the
104+
// flipping to `false` might happen before the check, negating the entire purpose of this hack? Fortunately,
105+
// before it's done, our async `res.end()` wrapper has to await a `setImmediate()` callback, guaranteeing its
106+
// run lasts at least until the next event loop. The check, on the other hand, happens synchronously,
107+
// immediately after the request handler (so in the same event loop). So as long as we wait to flip
108+
// `res.finished` back to `false` until after the `setImmediate` callback has run, we know we'll be safely in
109+
// the next event loop when we do so.
110+
//
111+
// And with that, everybody's happy: Nextjs doesn't complain about an unfinished response, `res.end()` doesn’t
112+
// complain about an already-finished response, and we have time to make sure events are flushed to Sentry.
113+
//
114+
// One final note: It might seem like making `res.end()` an awaited async function would run the danger of
115+
// having the lambda close before it's done its thing, meaning we *still* might not get events sent to Sentry.
116+
// Fortunately, even though it's called `res.end()`, and even though it's normally sync, a) it's far from the
117+
// end of the request process, so there's other stuff which needs to happen before the lambda can close in any
118+
// case, and b) that other stuff isn't triggered until `res.end()` emits a `prefinished` event, so even though
119+
// it's not technically awaited, it's still the case that the process can't go on until it's done.
120+
//
121+
// See
122+
// https://github.com/vercel/next.js/blob/e1464ae5a5061ae83ad015018d4afe41f91978b6/packages/next/server/api-utils.ts#L106-L118
123+
// and
124+
// https://github.com/nodejs/node/blob/d8f1823d5fca5e3c00b19530fb15343fdd3c8bf5/lib/_http_outgoing.js#L833-L911.
125+
res.finished = true;
126+
127+
return handlerResult;
78128
} catch (e) {
79129
// In case we have a primitive, wrap it in the equivalent wrapper class (string -> String, etc.) so that we can
80130
// store a seen flag on it. (Because of the one-way-on-Vercel-one-way-off-of-Vercel approach we've been forced
@@ -112,6 +162,16 @@ export const withSentry = (origHandler: NextApiHandler): WrappedNextApiHandler =
112162
type ResponseEndMethod = AugmentedResponse['end'];
113163
type WrappedResponseEndMethod = AugmentedResponse['end'];
114164

165+
/**
166+
* Wrap `res.end()` so that it closes the transaction and flushes events before letting the request finish.
167+
*
168+
* Note: This wraps a sync method with an async method. While in general that's not a great idea in terms of keeping
169+
* things in the right order, in this case it's safe', as explained in detail in the long comment in the main
170+
* `withSentry()` function.
171+
*
172+
* @param origEnd The original `res.end()` method
173+
* @returns The wrapped version
174+
*/
115175
function wrapEndMethod(origEnd: ResponseEndMethod): WrappedResponseEndMethod {
116176
return async function newEnd(this: AugmentedResponse, ...args: unknown[]) {
117177
const transaction = this.__sentryTransaction;
@@ -140,6 +200,11 @@ function wrapEndMethod(origEnd: ResponseEndMethod): WrappedResponseEndMethod {
140200
logger.log(`Error while flushing events:\n${e}`);
141201
}
142202

203+
// If the request didn't error, we will have temporarily marked the response finished to avoid a nextjs warning
204+
// message. (See long note above.) Now we need to flip `finished` back to `false` so that the real `res.end()`
205+
// method doesn't throw `ERR_STREAM_WRITE_AFTER_END` (which it will if presented with an already-finished response).
206+
this.finished = false;
207+
143208
return origEnd.call(this, ...args);
144209
};
145210
}

0 commit comments

Comments
 (0)