Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/big-laws-jam.md
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
121 changes: 98 additions & 23 deletions packages/open-next/src/adapters/image-optimization-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { FatalError, 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";
Expand Down Expand Up @@ -114,10 +115,18 @@ export async function defaultHandler(
);
return buildSuccessResponse(result, options?.streamCreator, etag);
} catch (e: any) {
error("Failed to optimize image", e);
// 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)
);
}
}
Expand Down Expand Up @@ -238,50 +247,116 @@ 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,
) {
const originalStatus = e.statusCode || e.$metadata?.httpStatusCode || 500;
const message = e.message || "Failed to process image request";

// Log all other errors as client errors
const clientError = new IgnorableError(message, originalStatus);
error("Failed to process image", clientError);

// For external images we throw with the status code
// Next.js will preserve the status code for external images
if (!isInternalImage) {
const statusCode = originalStatus >= 500 ? 400 : originalStatus;
throw new FatalError(message, statusCode);
}

// 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
res.setHeader("x-nextjs-internal-error", message);
res.end();
} else {
// For external images, maintain existing behavior with text/plain
res.setHeader("Content-Type", "text/plain");

// We should **never** send the error message to the client
// This is to prevent leaking sensitive information
res.end("Failed to process image request");
}
}

// 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
// remote image URL => download the image from the URL
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 `/`

// local image => download the image from the provided ImageLoader (default is S3)
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 ImageLoader", clientError);

// @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);
}
}
76 changes: 51 additions & 25 deletions packages/open-next/src/overrides/imageLoader/s3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,44 +2,70 @@ import type { Readable } from "node:stream";

import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
import type { ImageLoader } from "types/overrides";
import { FatalError } from "utils/error";
import { FatalError, IgnorableError } from "utils/error";

import { awsLogger } from "../../adapters/logger";
import { awsLogger, error } from "../../adapters/logger";

const { BUCKET_NAME, BUCKET_KEY_PREFIX } = process.env;

function ensureBucketExists() {
if (!BUCKET_NAME) {
throw new Error("Bucket name must be defined!");
throw new FatalError("Bucket name must be defined!");
}
}

const s3Loader: ImageLoader = {
name: "s3",
load: async (key: string) => {
const s3Client = new S3Client({ logger: awsLogger });

ensureBucketExists();
const keyPrefix = BUCKET_KEY_PREFIX?.replace(/^\/|\/$/g, "");
const response = await s3Client.send(
new GetObjectCommand({
Bucket: BUCKET_NAME,
Key: keyPrefix
? `${keyPrefix}/${key.replace(/^\//, "")}`
: key.replace(/^\//, ""),
}),
);
const body = response.Body as Readable | undefined;

if (!body) {
throw new FatalError("No body in S3 response");
}
try {
const s3Client = new S3Client({ logger: awsLogger });

ensureBucketExists();
const keyPrefix = BUCKET_KEY_PREFIX?.replace(/^\/|\/$/g, "");
const response = await s3Client.send(
new GetObjectCommand({
Bucket: BUCKET_NAME,
Key: keyPrefix
? `${keyPrefix}/${key.replace(/^\//, "")}`
: key.replace(/^\//, ""),
}),
);
const body = response.Body as Readable | undefined;

if (!body) {
throw new FatalError("No body in S3 response");
}

return {
body: body,
contentType: response.ContentType,
cacheControl: response.CacheControl,
};
return {
body: body,
contentType: response.ContentType,
cacheControl: response.CacheControl,
};
} catch (e: any) {
if (e instanceof FatalError) {
throw e;
}
// Special handling for S3 ListBucket permission errors
// AWS SDK v3 nests error details deeply within the error object
const isListBucketError =
(e.message.includes("s3:ListBucket") &&
e.message.includes("AccessDenied")) ||
e.error?.message?.includes("s3:ListBucket") ||
(e.Code === "AccessDenied" && e.Message?.includes("s3:ListBucket"));

if (isListBucketError) {
const statusCode =
e.statusCode === 500 && e.$metadata?.httpStatusCode === 403
? 403
: 500;
throw new IgnorableError(
"Image not found or access denied",
statusCode,
);
}
error("Failed to load image from S3", e);
throw new FatalError("Failed to load image from S3");
}
},
};

Expand Down
16 changes: 13 additions & 3 deletions packages/open-next/src/utils/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export interface BaseOpenNextError {
readonly canIgnore: boolean;
// 0 - debug, 1 - warn, 2 - error
readonly logLevel: 0 | 1 | 2;
readonly statusCode?: number;
}

// This is an error that can be totally ignored
Expand All @@ -11,9 +12,12 @@ export class IgnorableError extends Error implements BaseOpenNextError {
readonly __openNextInternal = true;
readonly canIgnore = true;
readonly logLevel = 0;
constructor(message: string) {
readonly statusCode: number;

constructor(message: string, statusCode = 400) {
super(message);
this.name = "IgnorableError";
this.statusCode = statusCode;
}
}

Expand All @@ -23,9 +27,12 @@ export class RecoverableError extends Error implements BaseOpenNextError {
readonly __openNextInternal = true;
readonly canIgnore = true;
readonly logLevel = 1;
constructor(message: string) {
readonly statusCode: number;

constructor(message: string, statusCode = 400) {
super(message);
this.name = "RecoverableError";
this.statusCode = statusCode;
}
}

Expand All @@ -34,9 +41,12 @@ export class FatalError extends Error implements BaseOpenNextError {
readonly __openNextInternal = true;
readonly canIgnore = false;
readonly logLevel = 2;
constructor(message: string) {
readonly statusCode: number;

constructor(message: string, statusCode = 500) {
super(message);
this.name = "FatalError";
this.statusCode = statusCode;
}
}

Expand Down
Loading