Skip to content
Merged
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
2 changes: 2 additions & 0 deletions src/low/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ describe("errorFromEnvelope", () => {
["queue_full", 429, QueueFull],
["insufficient_credits", 402, InsufficientCredits],
["not_found", 404, NotFound],
["job_not_found", 404, NotFound],
["asset_not_found", 404, NotFound],
["unauthorized", 401, Unauthorized],
["forbidden", 403, Forbidden],
];
Expand Down
5 changes: 5 additions & 0 deletions src/low/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,11 @@ const BY_CODE: Record<string, ApiErrorClass> = {
queue_full: QueueFull,
insufficient_credits: InsufficientCredits,
not_found: NotFound,
// public-api currently returns entity-specific 404 codes even though the spec
// documents the generic `not_found`; map them so callers still get a typed
// NotFound. (Server/spec reconciliation is a separate follow-up.)
job_not_found: NotFound,
asset_not_found: NotFound,
unauthorized: Unauthorized,
forbidden: Forbidden,
};
Expand Down
7 changes: 7 additions & 0 deletions src/low/transport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,13 @@ describe("ComfyLow transport", () => {
}
});

it("postAssets sends content_type before the file part", async () => {
// The stub rejects 422 if `file` precedes `content_type` (mirrors public-api).
const asset = await low.postAssets(new Blob(["hello"]), "text/plain", "hi.txt");
expect(asset.id).toBe("asset_uploaded_01");
expect(server.state.uploadCount).toBe(1); // 201, not a field-order rejection
});

it("postAssets surfaces hash_mismatch without a blind retry", async () => {
server.state.rejectHashMismatch = true;
await expect(
Expand Down
6 changes: 5 additions & 1 deletion src/low/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,8 +178,11 @@ export class ComfyLow {
timeoutMs?: number | null;
} = {},
): Promise<Asset> {
// public-api streams the multipart upload and requires the metadata fields
// BEFORE the file part (so it can route the file stream without buffering).
// The `file` part MUST be appended last, or the server rejects with 422
// "content_type is required and must be sent before the file field".
const form = new FormData();
form.append("file", file, filePath);
form.append("content_type", contentType);
form.append("file_path", filePath);
if (options.expectedHash !== undefined) {
Expand All @@ -188,6 +191,7 @@ export class ComfyLow {
for (const tag of options.tags ?? []) {
form.append("tags", tag);
}
form.append("file", file, filePath);
const headers: Record<string, string> = {};
if (options.idempotencyKey) {
headers["Idempotency-Key"] = options.idempotencyKey;
Expand Down
14 changes: 14 additions & 0 deletions test/support/stub-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,20 @@ export class StubServer {
state.uploadDataEvents += 1;
});
state.lastUploadContentLength = Number(req.headers["content-length"] ?? body.length);
// Mirror public-api: the multipart is streamed, so `content_type` MUST
// arrive before the `file` part or the server rejects.
const parts = body.toString("latin1");
const ctIdx = parts.indexOf('name="content_type"');
const fileIdx = parts.indexOf('name="file"');
if (fileIdx !== -1 && (ctIdx === -1 || fileIdx < ctIdx)) {
Comment thread
wei-hai marked this conversation as resolved.
sendError(
res,
422,
"invalid_body",
"content_type is required and must be sent before the file field",
);
return;
}
if (state.rejectHashMismatch) {
sendError(res, 409, "hash_mismatch", "bytes do not match expected_hash");
return;
Expand Down
Loading