From f672d369f5a255f94ca696bbfab81cfda3d2cf15 Mon Sep 17 00:00:00 2001 From: wei-hai Date: Tue, 21 Jul 2026 18:05:42 -0700 Subject: [PATCH 1/2] fix: send multipart content_type before the file part on asset upload public-api streams the multipart upload and requires content_type (and the other metadata fields) to arrive before the file part, or it rejects with 422 "content_type is required and must be sent before the file field". The SDK appended `file` first, so every asset upload against public-api failed. Append the metadata fields (content_type, file_path, expected_hash, tags) first and `file` last. The stub server now enforces the same ordering, so the existing upload tests guard against a regression. Co-Authored-By: Claude Opus 4.8 --- src/low/transport.test.ts | 7 +++++++ src/low/transport.ts | 6 +++++- test/support/stub-server.ts | 14 ++++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/low/transport.test.ts b/src/low/transport.test.ts index 5843655..a5a56e4 100644 --- a/src/low/transport.test.ts +++ b/src/low/transport.test.ts @@ -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( diff --git a/src/low/transport.ts b/src/low/transport.ts index d30af4e..6253168 100644 --- a/src/low/transport.ts +++ b/src/low/transport.ts @@ -178,8 +178,11 @@ export class ComfyLow { timeoutMs?: number | null; } = {}, ): Promise { + // 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) { @@ -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 = {}; if (options.idempotencyKey) { headers["Idempotency-Key"] = options.idempotencyKey; diff --git a/test/support/stub-server.ts b/test/support/stub-server.ts index 88f4572..fc64639 100644 --- a/test/support/stub-server.ts +++ b/test/support/stub-server.ts @@ -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)) { + 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; From 60a8cc162a156109ac64bc956cf4673be1f2a375 Mon Sep 17 00:00:00 2001 From: wei-hai Date: Tue, 21 Jul 2026 18:07:29 -0700 Subject: [PATCH 2/2] fix: map entity-specific 404 codes (job/asset_not_found) to NotFound public-api returns entity-specific 404 error codes (job_not_found, asset_not_found) even though the spec documents the generic not_found, so a missing job/asset surfaced as a bare ApiError instead of the typed NotFound the SDK promises. Map both to NotFound. (Server/spec reconciliation of the code set is a separate follow-up.) Co-Authored-By: Claude Opus 4.8 --- src/low/errors.test.ts | 2 ++ src/low/errors.ts | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/src/low/errors.test.ts b/src/low/errors.test.ts index 2772194..7a530f3 100644 --- a/src/low/errors.test.ts +++ b/src/low/errors.test.ts @@ -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], ]; diff --git a/src/low/errors.ts b/src/low/errors.ts index a9db179..72f8bc6 100644 --- a/src/low/errors.ts +++ b/src/low/errors.ts @@ -90,6 +90,11 @@ const BY_CODE: Record = { 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, };