-
Notifications
You must be signed in to change notification settings - Fork 160
feat(image-adapter): improve error handling and status codes #893
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
c9f536d
dcfe64b
6d36c0a
e7a51a1
eeea47c
6f0044b
aa36783
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
"@opennextjs/aws": major | ||
--- | ||
|
||
Improved error handling in image optimization adapter for S3 ListBucket permission errors |
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -28,6 +28,7 @@ import { emptyReadableStream, toReadableStream } from "utils/stream.js"; | |||||
import type { OpenNextHandlerOptions } from "types/overrides.js"; | ||||||
import { createGenericHandler } from "../core/createGenericHandler.js"; | ||||||
import { resolveImageLoader } from "../core/resolve.js"; | ||||||
import { IgnorableError } from "../utils/error.js"; | ||||||
import { debug, error } from "./logger.js"; | ||||||
import { optimizeImage } from "./plugins/image-optimization/image-optimization.js"; | ||||||
import { setNodeEnv } from "./util.js"; | ||||||
|
@@ -114,10 +115,19 @@ export async function defaultHandler( | |||||
); | ||||||
return buildSuccessResponse(result, options?.streamCreator, etag); | ||||||
} catch (e: any) { | ||||||
error("Failed to optimize image", e); | ||||||
// All image-related errors should be treated as client errors | ||||||
// Extract status code from error or default to 400 Bad Request | ||||||
const statusCode = e.statusCode || 400; | ||||||
const errorMessage = e.message || "Failed to process image request"; | ||||||
|
||||||
// Create an IgnorableError for proper monitoring classification | ||||||
const clientError = new IgnorableError(errorMessage, statusCode); | ||||||
error("Failed to optimize image", clientError); | ||||||
|
||||||
return buildFailureResponse( | ||||||
"Internal server error", | ||||||
errorMessage, | ||||||
options?.streamCreator, | ||||||
statusCode, // Use the appropriate status code (preserving original when available) | ||||||
); | ||||||
} | ||||||
} | ||||||
|
@@ -238,50 +248,140 @@ async function downloadHandler( | |||||
// directly. | ||||||
debug("downloadHandler url", url); | ||||||
|
||||||
// Reads the output from the Writable and writes to the response | ||||||
const pipeRes = (w: Writable, res: ServerResponse) => { | ||||||
w.pipe(res) | ||||||
/** | ||||||
* Helper function to handle image errors consistently with appropriate response | ||||||
* @param e The error object | ||||||
* @param res The server response object | ||||||
* @param isInternalImage Whether the error is from an internal image (S3) or external image | ||||||
*/ | ||||||
function handleImageError( | ||||||
e: any, | ||||||
res: ServerResponse, | ||||||
isInternalImage: boolean, | ||||||
) { | ||||||
let originalStatus = e.statusCode || e.$metadata?.httpStatusCode || 500; | ||||||
let message = e.message || "Failed to process image request"; | ||||||
|
||||||
// Special handling for S3 ListBucket permission errors | ||||||
// AWS SDK v3 nests error details deeply within the error object | ||||||
const isListBucketError = | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. All the S3 related stuff should not be here. https://github.com/opennextjs/opennextjs-aws/blob/main/packages/open-next/src/overrides/imageLoader/s3.ts |
||||||
(message.includes("s3:ListBucket") && message.includes("AccessDenied")) || | ||||||
e.error?.message?.includes("s3:ListBucket") || | ||||||
(e.Code === "AccessDenied" && e.Message?.includes("s3:ListBucket")); | ||||||
|
||||||
if (isListBucketError) { | ||||||
message = "Image not found or access denied"; | ||||||
// For S3 ListBucket errors, ensure we're using 403 (the actual AWS error) | ||||||
if (originalStatus === 500 && e.$metadata?.httpStatusCode === 403) { | ||||||
originalStatus = 403; | ||||||
} | ||||||
|
||||||
// Log using IgnorableError to classify as client error | ||||||
const clientError = new IgnorableError(message, originalStatus); | ||||||
error("S3 ListBucket permission error", clientError); | ||||||
} else { | ||||||
// Log all other errors as client errors | ||||||
const clientError = new IgnorableError(message, originalStatus); | ||||||
error("Failed to process image", clientError); | ||||||
} | ||||||
|
||||||
// For external images, throw if not ListBucket error | ||||||
// Next.js will preserve the status code for external images | ||||||
if (!isInternalImage && !isListBucketError) { | ||||||
const formattedError = new Error(message); | ||||||
// @ts-ignore: Add statusCode property to Error | ||||||
formattedError.statusCode = originalStatus >= 500 ? 400 : originalStatus; | ||||||
throw formattedError; | ||||||
} | ||||||
|
||||||
// Different handling for internal vs external images | ||||||
const finalStatus = originalStatus >= 500 ? 400 : originalStatus; | ||||||
res.statusCode = finalStatus; | ||||||
|
||||||
// For internal images, we want to trigger Next.js's "internal response invalid" message | ||||||
if (isInternalImage) { | ||||||
// For internal images, don't set Content-Type to trigger Next.js's default error handling | ||||||
// This should result in "url parameter is valid but internal response is invalid" | ||||||
|
||||||
// Still include error details in headers for debugging only | ||||||
const errorMessage = isListBucketError ? "Access denied" : message; | ||||||
res.setHeader("x-nextjs-internal-error", errorMessage); | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does this one propagates to the end user ? |
||||||
res.end(); | ||||||
} else { | ||||||
// For external images, maintain existing behavior with text/plain | ||||||
res.setHeader("Content-Type", "text/plain"); | ||||||
|
||||||
if (isListBucketError) { | ||||||
res.end("Access denied"); | ||||||
} else { | ||||||
res.end(message); | ||||||
} | ||||||
} | ||||||
} | ||||||
|
||||||
// Pipes data from a writable stream to the server response | ||||||
function pipeStream( | ||||||
stream: Writable, | ||||||
res: ServerResponse, | ||||||
isInternalImage: boolean, | ||||||
) { | ||||||
stream | ||||||
.pipe(res) | ||||||
.once("close", () => { | ||||||
res.statusCode = 200; | ||||||
res.end(); | ||||||
}) | ||||||
.once("error", (err) => { | ||||||
error("Failed to get image", err); | ||||||
res.statusCode = 400; | ||||||
res.end(); | ||||||
error("Error streaming image data", err); | ||||||
handleImageError(err, res, isInternalImage); | ||||||
}); | ||||||
}; | ||||||
} | ||||||
|
||||||
// Main handler logic with clearer error paths | ||||||
try { | ||||||
// Case 1: remote image URL => download the image from the URL | ||||||
// EXTERNAL IMAGE HANDLING | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
if (url.href.toLowerCase().match(/^https?:\/\//)) { | ||||||
pipeRes(https.get(url), res); | ||||||
try { | ||||||
pipeStream(https.get(url), res, false); | ||||||
} catch (e: any) { | ||||||
handleImageError(e, res, false); | ||||||
} | ||||||
return; | ||||||
} | ||||||
// Case 2: local image => download the image from S3 | ||||||
else { | ||||||
// Download image from S3 | ||||||
// note: S3 expects keys without leading `/` | ||||||
|
||||||
// INTERNAL IMAGE HANDLING (S3) | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
try { | ||||||
const response = await loader.load(url.href); | ||||||
|
||||||
// Handle empty response body | ||||||
if (!response.body) { | ||||||
throw new Error("Empty response body from the S3 request."); | ||||||
} | ||||||
const message = "Empty response body from the S3 request."; | ||||||
const clientError = new IgnorableError(message, 400); | ||||||
error("Empty response from S3", clientError); | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
|
||||||
// @ts-ignore | ||||||
pipeRes(response.body, res); | ||||||
res.statusCode = 400; | ||||||
res.setHeader("Content-Type", "text/plain"); | ||||||
res.end(message); | ||||||
return; | ||||||
} | ||||||
|
||||||
// Respect the bucket file's content-type and cache-control | ||||||
// imageOptimizer will use this to set the results.maxAge | ||||||
// Set headers from the response | ||||||
if (response.contentType) { | ||||||
res.setHeader("Content-Type", response.contentType); | ||||||
} | ||||||
if (response.cacheControl) { | ||||||
res.setHeader("Cache-Control", response.cacheControl); | ||||||
} | ||||||
|
||||||
// Stream the image to the client | ||||||
// @ts-ignore | ||||||
pipeStream(response.body, res, true); | ||||||
} catch (e: any) { | ||||||
// Direct response for all internal image errors | ||||||
handleImageError(e, res, true); | ||||||
} | ||||||
} catch (e: any) { | ||||||
error("Failed to download image", e); | ||||||
throw e; | ||||||
// Catch-all for any unexpected errors | ||||||
handleImageError(e, res, true); | ||||||
} | ||||||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not true, if something fails on the server it is not client errors