mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix(image-tools): surface Sharp encode failures instead of "Error: Error" (#534)
Wrap convert and gif-tools process functions so a Sharp .toBuffer() failure carries an authored SafeError title (and the original as cause) rather than a scrubbed "Error: Error".
This commit is contained in:
@@ -0,0 +1,40 @@
|
|||||||
|
import { isSafeMessageError, isToolInputError, SafeError } from "@snapotter/shared";
|
||||||
|
import type { ToolProcessCtx } from "../routes/tool-factory.js";
|
||||||
|
|
||||||
|
type ImageProcess<T> = (
|
||||||
|
inputBuffer: Buffer,
|
||||||
|
settings: T,
|
||||||
|
filename: string,
|
||||||
|
ctx?: ToolProcessCtx,
|
||||||
|
) => Promise<{ buffer: Buffer; filename: string; contentType: string }>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap an image tool's process function so an otherwise-opaque failure (most
|
||||||
|
* often a Sharp `.toBuffer()` that throws an empty-message Error) surfaces a
|
||||||
|
* safe, authored title instead of "Error: Error".
|
||||||
|
*
|
||||||
|
* The API's Sentry scrubber replaces any non-SafeError message with a type-only
|
||||||
|
* value (see `rebuildErrorValue`), so a bare Sharp failure is undiagnosable.
|
||||||
|
* Re-throwing as a SafeError makes the title survive while the original error is
|
||||||
|
* kept as `cause`, preserving its stack and exact location. Errors we already
|
||||||
|
* author (SafeError) or that flag bad user input (ToolInputError) pass through
|
||||||
|
* untouched so their class is not masked.
|
||||||
|
*/
|
||||||
|
export function withImageEncodeContext<T>(
|
||||||
|
message: string,
|
||||||
|
codeOf: (settings: T) => string,
|
||||||
|
process: ImageProcess<T>,
|
||||||
|
): ImageProcess<T> {
|
||||||
|
return async (inputBuffer, settings, filename, ctx) => {
|
||||||
|
try {
|
||||||
|
return await process(inputBuffer, settings, filename, ctx);
|
||||||
|
} catch (err) {
|
||||||
|
if (isSafeMessageError(err) || isToolInputError(err)) throw err;
|
||||||
|
throw new SafeError(message, {
|
||||||
|
kind: "bug",
|
||||||
|
code: codeOf(settings),
|
||||||
|
cause: err instanceof Error ? err : new Error(String(err)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
encodeTga,
|
encodeTga,
|
||||||
} from "../../lib/format-encoders.js";
|
} from "../../lib/format-encoders.js";
|
||||||
import { encodeHeic } from "../../lib/heic-converter.js";
|
import { encodeHeic } from "../../lib/heic-converter.js";
|
||||||
|
import { withImageEncodeContext } from "../../lib/image-error.js";
|
||||||
import { isSvgBuffer } from "../../lib/svg-sanitize.js";
|
import { isSvgBuffer } from "../../lib/svg-sanitize.js";
|
||||||
import { createToolRoute } from "../tool-factory.js";
|
import { createToolRoute } from "../tool-factory.js";
|
||||||
|
|
||||||
@@ -104,7 +105,10 @@ export function registerConvert(app: FastifyInstance) {
|
|||||||
createToolRoute(app, {
|
createToolRoute(app, {
|
||||||
toolId: "convert",
|
toolId: "convert",
|
||||||
settingsSchema,
|
settingsSchema,
|
||||||
process: async (inputBuffer, settings, filename) => {
|
process: withImageEncodeContext<z.infer<typeof settingsSchema>>(
|
||||||
|
"Image conversion failed",
|
||||||
|
(s) => s.format,
|
||||||
|
async (inputBuffer, settings, filename) => {
|
||||||
// CLI-encoded formats bypass Sharp entirely
|
// CLI-encoded formats bypass Sharp entirely
|
||||||
const cliEncoder = CLI_ENCODERS[settings.format];
|
const cliEncoder = CLI_ENCODERS[settings.format];
|
||||||
if (cliEncoder) {
|
if (cliEncoder) {
|
||||||
@@ -161,5 +165,6 @@ export function registerConvert(app: FastifyInstance) {
|
|||||||
|
|
||||||
return { buffer, filename: outputFilename, contentType };
|
return { buffer, filename: outputFilename, contentType };
|
||||||
},
|
},
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
|||||||
import { zipSync } from "fflate";
|
import { zipSync } from "fflate";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { withImageEncodeContext } from "../../lib/image-error.js";
|
||||||
import { createToolRoute } from "../tool-factory.js";
|
import { createToolRoute } from "../tool-factory.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -146,7 +147,10 @@ export function registerGifTools(app: FastifyInstance) {
|
|||||||
createToolRoute(app, {
|
createToolRoute(app, {
|
||||||
toolId: "gif-tools",
|
toolId: "gif-tools",
|
||||||
settingsSchema,
|
settingsSchema,
|
||||||
process: async (inputBuffer, settings, filename) => {
|
process: withImageEncodeContext<z.infer<typeof settingsSchema>>(
|
||||||
|
"GIF processing failed",
|
||||||
|
(s) => s.mode,
|
||||||
|
async (inputBuffer, settings, filename) => {
|
||||||
const baseName = filename.replace(/\.[^.]+$/, "");
|
const baseName = filename.replace(/\.[^.]+$/, "");
|
||||||
const loop = settings.loop;
|
const loop = settings.loop;
|
||||||
|
|
||||||
@@ -304,5 +308,6 @@ export function registerGifTools(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { isSafeMessageError, markToolInputError, SafeError } from "@snapotter/shared";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { withImageEncodeContext } from "../../../apps/api/src/lib/image-error.js";
|
||||||
|
|
||||||
|
interface Settings {
|
||||||
|
format: string;
|
||||||
|
}
|
||||||
|
const settings: Settings = { format: "webp" };
|
||||||
|
const input = Buffer.from("");
|
||||||
|
|
||||||
|
describe("withImageEncodeContext", () => {
|
||||||
|
it("returns the process result unchanged when it succeeds", async () => {
|
||||||
|
const wrapped = withImageEncodeContext(
|
||||||
|
"Image conversion failed",
|
||||||
|
(s: Settings) => s.format,
|
||||||
|
async () => ({ buffer: Buffer.from("ok"), filename: "out.webp", contentType: "image/webp" }),
|
||||||
|
);
|
||||||
|
const result = await wrapped(input, settings, "in.png");
|
||||||
|
expect(result.filename).toBe("out.webp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps an opaque encode failure in a SafeError with the target format as code", async () => {
|
||||||
|
// Sharp .toBuffer() failures throw an Error whose message is scrubbed to
|
||||||
|
// type-only ("Error: Error") in Sentry; the wrapper must author a title.
|
||||||
|
const sharpErr = new Error("");
|
||||||
|
const wrapped = withImageEncodeContext(
|
||||||
|
"Image conversion failed",
|
||||||
|
(s: Settings) => s.format,
|
||||||
|
async () => {
|
||||||
|
throw sharpErr;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let caught: unknown;
|
||||||
|
try {
|
||||||
|
await wrapped(input, settings, "in.png");
|
||||||
|
} catch (e) {
|
||||||
|
caught = e;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(isSafeMessageError(caught)).toBe(true);
|
||||||
|
expect((caught as SafeError).message).toBe("Image conversion failed");
|
||||||
|
expect((caught as SafeError).kind).toBe("bug");
|
||||||
|
expect((caught as SafeError).code).toBe("webp");
|
||||||
|
// Original error kept so its stack/location survives in the cause chain.
|
||||||
|
expect((caught as SafeError).cause).toBe(sharpErr);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes an already-authored SafeError through unchanged (no double-wrap)", async () => {
|
||||||
|
const inner = new SafeError("Process killed (out of memory)", { kind: "operational" });
|
||||||
|
const wrapped = withImageEncodeContext(
|
||||||
|
"Image conversion failed",
|
||||||
|
() => "webp",
|
||||||
|
async () => {
|
||||||
|
throw inner;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await expect(wrapped(input, settings, "in.png")).rejects.toBe(inner);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes a ToolInputError through unchanged (stays a 400, not a masked bug)", async () => {
|
||||||
|
const inputErr = markToolInputError(new Error("Unsupported input"));
|
||||||
|
const wrapped = withImageEncodeContext(
|
||||||
|
"Image conversion failed",
|
||||||
|
() => "webp",
|
||||||
|
async () => {
|
||||||
|
throw inputErr;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await expect(wrapped(input, settings, "in.png")).rejects.toBe(inputErr);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user