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>
118 lines
4.4 KiB
TypeScript
118 lines
4.4 KiB
TypeScript
import { trimTrailingSlashes } from '../utils';
|
||
import { SIGNATURE_BYTES, signatureMatches } from '../uploadTypes';
|
||
|
||
/**
|
||
* The one place that talks to the background-removal sidecar.
|
||
*
|
||
* A sidecar rather than in-process inference: the application runs in a
|
||
* container, and putting Python and ONNX into the image would add roughly
|
||
* 300 MB to one already over a gigabyte. See
|
||
* docs/ops/image-background-removal-stack.md for the measurements.
|
||
*/
|
||
|
||
/**
|
||
* NEVER remove this, and never make it configurable.
|
||
*
|
||
* The sidecar's default model is `bria-rmbg`, and BRIA's RMBG models are
|
||
* licensed for NON-COMMERCIAL use. This is a shop. The default is reached by
|
||
* simply not naming a model, so it is a licensing problem that happens
|
||
* silently and produces a perfectly good image — there is nothing in the
|
||
* output that could reveal it.
|
||
*
|
||
* `u2net` is Apache-2.0, and also ten times faster (1.1–2.3 s against
|
||
* 14–20 s) at a sixth the size, so nothing is being traded away for it.
|
||
*/
|
||
const MODEL = 'u2net';
|
||
|
||
/**
|
||
* Generous on purpose. The sidecar takes about 40 seconds to answer after a
|
||
* container start and its first call per model downloads 168 MB, so a tight
|
||
* timeout would turn an ordinary cold start into a failure. Nobody is waiting
|
||
* on this in the worker's path, and an admin who clicked a button would rather
|
||
* wait than be told it did not work.
|
||
*/
|
||
const TIMEOUT_MS = 120_000;
|
||
|
||
/**
|
||
* Thrown only when the sidecar was actually contacted and did not answer
|
||
* usably — unreachable, timed out, answered with a non-2xx status, or
|
||
* answered with something that is not a PNG.
|
||
*
|
||
* Deliberately not thrown for "REMBG_URL is not set": that failure happens
|
||
* before any attempt to contact anything, so lumping it in here would tell a
|
||
* caller "the service did not answer" about a service nothing ever tried to
|
||
* reach. A caller distinguishes the two to avoid exactly that (#281 review).
|
||
*/
|
||
export class SidecarRequestError extends Error {}
|
||
|
||
/** The configured base URL, or null when there is none. */
|
||
function baseUrl(): string | null {
|
||
const raw = process.env.REMBG_URL;
|
||
if (raw === undefined || raw.trim() === '') return null;
|
||
return trimTrailingSlashes(raw.trim());
|
||
}
|
||
|
||
/**
|
||
* Whether the feature exists in this environment.
|
||
*
|
||
* Unconfigured is not a failure. It means the submitter sees no checkbox, the
|
||
* admin sees no control and the worker skips the step — an unconfigured
|
||
* environment must be a working one, which is the same rule
|
||
* `getAnthropicClient` follows by returning null rather than throwing.
|
||
*/
|
||
export function isRembgConfigured(): boolean {
|
||
return baseUrl() !== null;
|
||
}
|
||
|
||
/**
|
||
* The cut-out, as PNG bytes.
|
||
*
|
||
* Rejects on every failure — unconfigured, unreachable, a non-2xx answer, or a
|
||
* body that is not actually a PNG. Every caller catches, and none of them lets
|
||
* the rejection reach a submission or a draft.
|
||
*/
|
||
export async function removeBackground(bytes: Buffer, mediaType: string): Promise<Buffer> {
|
||
const base = baseUrl();
|
||
if (base === null) {
|
||
throw new Error('REMBG_URL is not set');
|
||
}
|
||
|
||
const body = new FormData();
|
||
// A copy through Uint8Array because Buffer is not a BlobPart. The filename is
|
||
// a constant: the sidecar does not use it, and passing the stored name would
|
||
// put a value from the uploads volume into an outbound request for nothing.
|
||
body.append('file', new Blob([new Uint8Array(bytes)], { type: mediaType }), 'photo');
|
||
body.append('model', MODEL);
|
||
|
||
let res: Response;
|
||
try {
|
||
res = await fetch(`${base}/api/remove`, {
|
||
method: 'POST',
|
||
body,
|
||
signal: AbortSignal.timeout(TIMEOUT_MS)
|
||
});
|
||
} catch (err) {
|
||
// Unreachable, refused, or timed out — fetch throws for all three rather
|
||
// than returning a response, so this is the only place that can catch
|
||
// them and mark them as a sidecar failure rather than a generic error.
|
||
throw new SidecarRequestError(
|
||
`rembg did not answer: ${err instanceof Error ? err.message : String(err)}`
|
||
);
|
||
}
|
||
|
||
if (!res.ok) {
|
||
throw new SidecarRequestError(`rembg answered ${res.status}`);
|
||
}
|
||
|
||
const out = Buffer.from(await res.arrayBuffer());
|
||
|
||
// The bytes, not the Content-Type header. A proxy error page served as
|
||
// image/png would otherwise be written over a photograph — the same reason
|
||
// uploads are checked by signature rather than by what the caller declared.
|
||
if (!signatureMatches('image/png', out.subarray(0, SIGNATURE_BYTES))) {
|
||
throw new SidecarRequestError('rembg response is not a PNG');
|
||
}
|
||
|
||
return out;
|
||
}
|