Skip to content

Commit 6f0044b

Browse files
committed
refactor: improve error handling and logging for image optimization adapter
1 parent eeea47c commit 6f0044b

File tree

1 file changed

+125
-156
lines changed

1 file changed

+125
-156
lines changed

packages/open-next/src/adapters/image-optimization-adapter.ts

Lines changed: 125 additions & 156 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,7 @@ import { emptyReadableStream, toReadableStream } from "utils/stream.js";
2828
import type { OpenNextHandlerOptions } from "types/overrides.js";
2929
import { createGenericHandler } from "../core/createGenericHandler.js";
3030
import { resolveImageLoader } from "../core/resolve.js";
31-
import {
32-
FatalError,
33-
IgnorableError,
34-
type RecoverableError,
35-
} from "../utils/error.js";
31+
import { IgnorableError } from "../utils/error.js";
3632
import { debug, error } from "./logger.js";
3733
import { optimizeImage } from "./plugins/image-optimization/image-optimization.js";
3834
import { setNodeEnv } from "./util.js";
@@ -87,9 +83,10 @@ export async function defaultHandler(
8783
// We return a 400 here if imageParams returns an errorMessage
8884
// https://github.com/vercel/next.js/blob/512d8283054407ab92b2583ecce3b253c3be7b85/packages/next/src/server/next-server.ts#L937-L941
8985
if ("errorMessage" in imageParams) {
90-
// Use IgnorableError for client-side validation issues (logLevel 0) to prevent monitoring alerts
91-
const clientError = new IgnorableError(imageParams.errorMessage, 400);
92-
error("Error during validation of image params", clientError);
86+
error(
87+
"Error during validation of image params",
88+
imageParams.errorMessage,
89+
);
9390
return buildFailureResponse(
9491
imageParams.errorMessage,
9592
options?.streamCreator,
@@ -118,17 +115,19 @@ export async function defaultHandler(
118115
);
119116
return buildSuccessResponse(result, options?.streamCreator, etag);
120117
} catch (e: any) {
121-
// Determine if this is a client error (4xx) and convert it to appropriate error type
122-
const classifiedError = classifyError(e);
118+
// All image-related errors should be treated as client errors
119+
// Extract status code from error or default to 400 Bad Request
120+
const statusCode = e.statusCode || 400;
121+
const errorMessage = e.message || "Failed to process image request";
123122

124-
// Log with the appropriate level based on the error type
125-
error("Image optimization error", classifiedError);
123+
// Create an IgnorableError for proper monitoring classification
124+
const clientError = new IgnorableError(errorMessage, statusCode);
125+
error("Failed to optimize image", clientError);
126126

127-
// Pass through the error message from Next.js
128127
return buildFailureResponse(
129-
classifiedError.message || "Internal server error",
128+
errorMessage,
130129
options?.streamCreator,
131-
classifiedError.statusCode,
130+
statusCode, // Use the appropriate status code (preserving original when available)
132131
);
133132
}
134133
}
@@ -137,52 +136,6 @@ export async function defaultHandler(
137136
// Helper functions //
138137
//////////////////////
139138

140-
/**
141-
* Classifies an error as either a client error (IgnorableError) or server error (FatalError)
142-
* to ensure proper logging behavior without triggering false monitoring alerts.
143-
*
144-
* The primary goal is to preserve the original error information while ensuring
145-
* client errors don't trigger monitoring alerts.
146-
*/
147-
function classifyError(e: any): IgnorableError | RecoverableError | FatalError {
148-
// If it's already an OpenNext error, return it directly
149-
if (e && typeof e === "object" && "__openNextInternal" in e) {
150-
return e;
151-
}
152-
153-
// Preserve the original message
154-
const message = e?.message || "Internal server error";
155-
156-
// Preserve the original status code if available, otherwise use a default
157-
let statusCode = 500;
158-
if (
159-
e &&
160-
typeof e === "object" &&
161-
"statusCode" in e &&
162-
typeof e.statusCode === "number"
163-
) {
164-
statusCode = e.statusCode;
165-
}
166-
167-
// Simple check for client errors - anything with a 4xx status code
168-
// or common error codes that indicate client issues
169-
const isClientError =
170-
(statusCode >= 400 && statusCode < 500) ||
171-
(e &&
172-
typeof e === "object" &&
173-
(e.code === "ENOTFOUND" ||
174-
e.code === "ECONNREFUSED" ||
175-
e.code === "ETIMEDOUT"));
176-
177-
// Wrap client errors as IgnorableError to prevent monitoring alerts
178-
if (isClientError) {
179-
return new IgnorableError(message, statusCode);
180-
}
181-
182-
// Server errors are marked as FatalError to ensure proper monitoring
183-
return new FatalError(message, statusCode);
184-
}
185-
186139
function validateImageParams(
187140
headers: OutgoingHttpHeaders,
188141
query?: InternalEvent["query"],
@@ -295,124 +248,140 @@ async function downloadHandler(
295248
// directly.
296249
debug("downloadHandler url", url);
297250

298-
// Reads the output from the Writable and writes to the response
299-
const pipeRes = (w: Writable, res: ServerResponse) => {
300-
w.pipe(res)
251+
/**
252+
* Helper function to handle image errors consistently with appropriate response
253+
* @param e The error object
254+
* @param res The server response object
255+
* @param isInternalImage Whether the error is from an internal image (S3) or external image
256+
*/
257+
function handleImageError(
258+
e: any,
259+
res: ServerResponse,
260+
isInternalImage: boolean,
261+
) {
262+
let originalStatus = e.statusCode || e.$metadata?.httpStatusCode || 500;
263+
let message = e.message || "Failed to process image request";
264+
265+
// Special handling for S3 ListBucket permission errors
266+
// AWS SDK v3 nests error details deeply within the error object
267+
const isListBucketError =
268+
(message.includes("s3:ListBucket") && message.includes("AccessDenied")) ||
269+
e.error?.message?.includes("s3:ListBucket") ||
270+
(e.Code === "AccessDenied" && e.Message?.includes("s3:ListBucket"));
271+
272+
if (isListBucketError) {
273+
message = "Image not found or access denied";
274+
// For S3 ListBucket errors, ensure we're using 403 (the actual AWS error)
275+
if (originalStatus === 500 && e.$metadata?.httpStatusCode === 403) {
276+
originalStatus = 403;
277+
}
278+
279+
// Log using IgnorableError to classify as client error
280+
const clientError = new IgnorableError(message, originalStatus);
281+
error("S3 ListBucket permission error", clientError);
282+
} else {
283+
// Log all other errors as client errors
284+
const clientError = new IgnorableError(message, originalStatus);
285+
error("Failed to process image", clientError);
286+
}
287+
288+
// For external images, throw if not ListBucket error
289+
// Next.js will preserve the status code for external images
290+
if (!isInternalImage && !isListBucketError) {
291+
const formattedError = new Error(message);
292+
// @ts-ignore: Add statusCode property to Error
293+
formattedError.statusCode = originalStatus >= 500 ? 400 : originalStatus;
294+
throw formattedError;
295+
}
296+
297+
// Different handling for internal vs external images
298+
const finalStatus = originalStatus >= 500 ? 400 : originalStatus;
299+
res.statusCode = finalStatus;
300+
301+
// For internal images, we want to trigger Next.js's "internal response invalid" message
302+
if (isInternalImage) {
303+
// For internal images, don't set Content-Type to trigger Next.js's default error handling
304+
// This should result in "url parameter is valid but internal response is invalid"
305+
306+
// Still include error details in headers for debugging only
307+
const errorMessage = isListBucketError ? "Access denied" : message;
308+
res.setHeader("x-nextjs-internal-error", errorMessage);
309+
res.end();
310+
} else {
311+
// For external images, maintain existing behavior with text/plain
312+
res.setHeader("Content-Type", "text/plain");
313+
314+
if (isListBucketError) {
315+
res.end("Access denied");
316+
} else {
317+
res.end(message);
318+
}
319+
}
320+
}
321+
322+
// Pipes data from a writable stream to the server response
323+
function pipeStream(
324+
stream: Writable,
325+
res: ServerResponse,
326+
isInternalImage: boolean,
327+
) {
328+
stream
329+
.pipe(res)
301330
.once("close", () => {
302331
res.statusCode = 200;
303332
res.end();
304333
})
305334
.once("error", (err) => {
306-
error("Failed to get image", err);
307-
res.statusCode = 400;
308-
res.end();
335+
error("Error streaming image data", err);
336+
handleImageError(err, res, isInternalImage);
309337
});
310-
};
338+
}
311339

340+
// Main handler logic with clearer error paths
312341
try {
313-
// Case 1: remote image URL => download the image from the URL
342+
// EXTERNAL IMAGE HANDLING
314343
if (url.href.toLowerCase().match(/^https?:\/\//)) {
315-
const request = https.get(url, (response) => {
316-
// Check for HTTP error status codes
317-
if (response.statusCode && response.statusCode >= 400) {
318-
// Create an IgnorableError with appropriate status code
319-
const clientError = new IgnorableError(
320-
response.statusMessage || `HTTP error ${response.statusCode}`,
321-
response.statusCode,
322-
);
323-
324-
// Log the error using proper error logger to handle it correctly
325-
error("Client error fetching image", clientError, {
326-
status: response.statusCode,
327-
statusText: response.statusMessage,
328-
url: url.href,
329-
});
330-
331-
res.statusCode = response.statusCode;
332-
res.end();
333-
return;
334-
}
335-
336-
// IncomingMessage is a Readable stream, not a Writable
337-
// We need to pipe it directly to the response
338-
response
339-
.pipe(res)
340-
.once("close", () => {
341-
if (!res.headersSent) {
342-
res.statusCode = 200;
343-
}
344-
res.end();
345-
})
346-
.once("error", (pipeErr: Error) => {
347-
const clientError = new IgnorableError(
348-
`Error during image piping: ${pipeErr.message}`,
349-
400,
350-
);
351-
error("Failed to get image during piping", clientError);
352-
if (!res.headersSent) {
353-
res.statusCode = 400;
354-
}
355-
res.end();
356-
});
357-
});
358-
359-
request.on("error", (err: Error & { code?: string }) => {
360-
// For network errors, convert to appropriate error type based on error code
361-
const isClientError =
362-
err.code === "ENOTFOUND" || err.code === "ECONNREFUSED";
363-
const statusCode = isClientError ? 404 : 400;
364-
365-
// Create appropriate error type
366-
const clientError = new IgnorableError(
367-
err.message || `Error fetching image: ${err.code || "unknown error"}`,
368-
statusCode,
369-
);
370-
371-
// Log with error function but it will be handled properly based on error type
372-
error("Error fetching image", clientError, {
373-
code: err.code,
374-
message: err.message,
375-
url: url.href,
376-
});
377-
378-
res.statusCode = statusCode;
379-
res.end();
380-
});
344+
try {
345+
pipeStream(https.get(url), res, false);
346+
} catch (e: any) {
347+
handleImageError(e, res, false);
348+
}
349+
return;
381350
}
382-
// Case 2: local image => download the image from S3
383-
else {
384-
// Download image from S3
385-
// note: S3 expects keys without leading `/`
386351

352+
// INTERNAL IMAGE HANDLING (S3)
353+
try {
387354
const response = await loader.load(url.href);
388355

356+
// Handle empty response body
389357
if (!response.body) {
390-
throw new Error("Empty response body from the S3 request.");
391-
}
358+
const message = "Empty response body from the S3 request.";
359+
const clientError = new IgnorableError(message, 400);
360+
error("Empty response from S3", clientError);
392361

393-
// @ts-ignore
394-
pipeRes(response.body, res);
362+
res.statusCode = 400;
363+
res.setHeader("Content-Type", "text/plain");
364+
res.end(message);
365+
return;
366+
}
395367

396-
// Respect the bucket file's content-type and cache-control
397-
// imageOptimizer will use this to set the results.maxAge
368+
// Set headers from the response
398369
if (response.contentType) {
399370
res.setHeader("Content-Type", response.contentType);
400371
}
401372
if (response.cacheControl) {
402373
res.setHeader("Cache-Control", response.cacheControl);
403374
}
375+
376+
// Stream the image to the client
377+
// @ts-ignore
378+
pipeStream(response.body, res, true);
379+
} catch (e: any) {
380+
// Direct response for all internal image errors
381+
handleImageError(e, res, true);
404382
}
405383
} catch (e: any) {
406-
// Use our centralized error classification function
407-
const classifiedError = classifyError(e);
408-
409-
// Log error with appropriate level based on error type
410-
// This will automatically downgrade client errors to debug level
411-
error("Failed to download image", classifiedError);
412-
413-
// Since we're in a middleware (adapter) called by Next.js's image optimizer,
414-
// we should throw the properly classified error to let Next.js handle it appropriately
415-
// The Next.js image optimizer will use the status code and normalize the error message
416-
throw classifiedError;
384+
// Catch-all for any unexpected errors
385+
handleImageError(e, res, true);
417386
}
418387
}

0 commit comments

Comments
 (0)