fix(admin): stop reporting every removeImageBackground failure as a sidecar failure (#281)

The remove-background route's catch block turned every throw from removeImageBackground into a 502 "the background-removal service did not answer". But removeImageBackground also throws for an unrecognised file extension (a legacy .jpeg), for a file missing from the uploads volume, and when REMBG_URL is not set at all — none of which involve contacting the sidecar. The admin was told to retry a service that was never reached, while the real reason existed only in the server log.

Added SidecarRequestError in rembgClient.ts, following the NoOriginalToRestoreError pattern already in backgroundRemoval.ts. It is thrown only for failures that happen after actually attempting to reach the sidecar: the fetch call itself throwing (now wrapped in a try/catch, covering unreachable and timed-out), a non-2xx response, or a response that is not a PNG. It is deliberately not thrown for "REMBG_URL is not set", since that path never attempts contact at all.

The remove-background handler now checks err instanceof SidecarRequestError before answering 502; everything else answers 500 with a message that says what actually went wrong.

Added a unit test pairing (rembgClient.test.ts) asserting the sidecar-contacted failures are SidecarRequestError and the unconfigured case is not, and an integration test (adminItemDrafts.integration.test.ts) proving a missing upload file answers something other than 502 with a message that does not claim the service did not answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 13:42:47 -05:00
co-authored by Claude Opus 5
parent b06eac4640
commit dfd900aadd
4 changed files with 87 additions and 16 deletions
@@ -469,6 +469,24 @@ describe('the review queues background-removal control', () => {
expect(rows[0]?.image_path).toBe('/uploads/original.jpg');
});
// MINOR 3 (#281 review): a failure that happens before the sidecar is ever
// contacted — here, the file the row points to is missing from the uploads
// volume — must not be reported as "the service did not answer". That sends
// the admin to retry a service that was never reached, and hides the real
// reason in the server log.
it('does not answer 502 when the photo cannot be read, even though a sidecar is configured', async () => {
await startStub(200, PNG_BYTES);
const { itemId, imageId } = await seedDraftWithImage();
await fsp.unlink(path.join(uploads, 'original.jpg'));
const res = await request(app).post(
`/api/admin/item-drafts/${itemId}/images/${imageId}/remove-background`
);
expect(res.status).not.toBe(502);
expect(res.body.error).not.toMatch(/did not answer/);
});
// Scoped by item as well as by image. The id is a serial, so guessing one is
// not hard, and a photo from another submission must not be reachable
// through this item's URL.
+14 -2
View File
@@ -1,6 +1,6 @@
import http from 'http';
import { AddressInfo } from 'net';
import { isRembgConfigured, removeBackground } from '../../src/intake/rembgClient';
import { isRembgConfigured, removeBackground, SidecarRequestError } from '../../src/intake/rembgClient';
/** A real PNG header, so the client's own signature check sees what it expects. */
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]);
@@ -108,6 +108,9 @@ describe('asking the sidecar to remove a background', () => {
},
async () => {
await expect(removeBackground(JPEG, 'image/jpeg')).rejects.toThrow(/500/);
await expect(removeBackground(JPEG, 'image/jpeg')).rejects.toBeInstanceOf(
SidecarRequestError
);
}
);
});
@@ -123,12 +126,21 @@ describe('asking the sidecar to remove a background', () => {
},
async () => {
await expect(removeBackground(JPEG, 'image/jpeg')).rejects.toThrow(/not a PNG/);
await expect(removeBackground(JPEG, 'image/jpeg')).rejects.toBeInstanceOf(
SidecarRequestError
);
}
);
});
it('rejects when it is not configured at all', async () => {
// Deliberately not a SidecarRequestError (MINOR 3, #281 review): this
// failure happens before any attempt to contact the sidecar, and a caller
// has to be able to tell "never tried" apart from "tried and failed".
it('rejects when it is not configured at all, without it being a sidecar failure', async () => {
delete process.env.REMBG_URL;
await expect(removeBackground(JPEG, 'image/jpeg')).rejects.toThrow(/REMBG_URL/);
await expect(removeBackground(JPEG, 'image/jpeg')).rejects.not.toBeInstanceOf(
SidecarRequestError
);
});
});