mirror of
https://github.com/Comfy-Org/comfy-python-sdk.git
synced 2026-11-04 23:37:47 +00:00
[GH-ISSUE #62] Public error contract is untested, and two defects hide behind it: translating() skipped on all download paths, AsyncOutput.to_stream missing #7
Labels
No labels
audit:ai-readiness
audit:community
audit:github
audit:health-scoring
audit:policy
audit:release
audit:supply-chain
effort:medium
effort:small
effort:small
effort:trivial
priority:nice-to-have
priority:nice-to-have
priority:recommended
pull-request
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Comfy-Org/comfy-python-sdk#7
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Originally created by @christian-byrne on GitHub (Aug 21, 2026).
Original GitHub issue: https://github.com/Comfy-Org/comfy-python-sdk/issues/62
Originally assigned to: @wei-hai on GitHub.
Summary
A test-coverage audit of
origin/main(c96eb09) turned up two verified defects that share oneroot cause: the guarantees the README and docstrings make about the public surface have no test
behind them. Everything else in the audit was clean — including codegen drift, which is guarded
and enforced as a required check (details at the bottom).
Both defects trace to the same code and the same guarantee, so they are batched here rather than
filed per-module.
Defect 1 —
translating()is applied inconsistently; 10 public entry points leak the rawcomfy_low.ApiErrorcomfy_sdk/exceptions.py::translatingdocuments the contract:The README's "Typed errors" section makes the same promise: every error is a
ComfyErrorsubclass.These entry points do not wrap, and raise
comfy_low.errors.ApiErrorstraight through:Output.to_file/to_stream/to_bytes/get_download_urlsrc/comfy_sdk/outputs.py— the module never importstranslatingat allAsyncOutput.to_file/to_bytes/get_download_urlAssetFactory.get/AsyncAssetFactory.getsrc/comfy_sdk/assets.py:271,:322JobFactory.get/AsyncJobFactory.getsrc/comfy_sdk/jobs.py:301,:309Job.events()/AsyncJob.events()— the non-501raisesrc/comfy_sdk/jobs.py:516,:628Why this bites harder than a normal type mismatch:
comfy_low.errorsandcomfy_sdk.exceptionsboth export a class namedNotFound, and they are unrelated(
ApiErrorvsComfyError). So a consumer writing the documentedsilently never catches. And for the entity-specific server codes (
job_not_found,asset_not_found),comfy_low._BY_CODEhas no entry either, so what escapes is a barecomfy_low.errors.ApiError— not even the protocol-levelNotFound.This is the output-download path, i.e. the most-used consumer call after
run().Reproduction
Driven by this repo's own stub server (
tests/conftest.py), no mocks. Controls first — twoentry points that are wrapped, on the same code paths, to show the harness detects correct
behaviour:
Output:
JobFactory.getneeds a server that 404s a job (the stub returns a job for any id); against aminimal 404-everything handler it leaks the same way:
Suggested fix
Wrap the four
outputs.pydownload methods (sync + async) and both factories'get()inwith translating():, and translate before theraiseinevents(). Then add the test thatwould have caught it — one that asserts no
comfy_low.ApiErrorescapes any public entry point.Defect 2 —
Output.to_streamhas noAsyncOutputcounterpart, and no testsrc/comfy_sdk/outputs.py:89definesOutput.to_stream(...).AsyncOutputhas noto_stream.The README states:
and closes the downloads section with "(
AsyncOutputmirrors all of the above withawait.)".A public-surface diff over every sync/async pair — 6 of 7 are identical, one is not:
(the check normalises the intentional
close/acloserename)to_streamis also the only download method with zero test references anywhere intests/—which is why the asymmetry went unnoticed. Fix is
async def to_streamonAsyncOutputplus aparity test asserting the sync and async public method sets match.
The coverage checklist behind them
Ordered by consumer impact. These are gaps, not known bugs.
translating()guarantee across every public entry point — the one that catches Defect 1 and any future regression of it.AssetFactory.from_url/AsyncAssetFactory.from_url— zero tests. It is one of four README-documented asset constructors and the only one that opens its ownhttpxclient, so it silently ignores the client'stimeout=andclient_info.Preview.to_pil()— zero tests. README showsshow(pv.to_pil())and the repo ships apilextra for it; Pillow is already in[dev].comfy_low.errors.error_from_envelope— zero direct tests, and the_CODE_BY_STATUSstatus-derived fallback is entirely dark.tests/conftest.py::_erralways writes a well-formed JSON envelope, so the "bare 401/404/429 with no JSON body" branch that the docstring exists for never runs. That is exactly the branch that fires when an nginx/LB/proxy answers instead of the API — the self-hostedcomfy-api-proxydeployment the README targets.to_sdk_error— 3 of 13 code mappings tested.insufficient_credits,forbidden,blob_not_foundappear nowhere intests/. The table is currently complete, so this is a guard gap rather than a live bug — worth closing the same waytest_spec_coverage.pyalready closes it for operationIds: assertcomfy_low.errors._BY_CODE's code set is fully mapped bycomfy_sdk.exceptions.raw_request,open, all-headers, per-request timeout/abort — have no contract test. Only SSEtimeout=Noneis exercised._core.backoff_schedule— zero direct tests;wait()never polls long enough to reachcap.head_asset_by_hash's non-200/404 branch — zero tests; the stub only answers 200/404.What was checked and is clean
Recording these so nobody re-runs them:
scripts/check_drift.pyregenerates into a tempdir and compares byte-for-byte, so both a spec edit without a regen and a hand-edit of_generated.pyare caught. Thecomfy_low codegen driftjob runs it on every PR and push, and it is a required status check onmain(verified atrepos/.../branches/main/protection, not inferred from the workflow file). The generator is pinned and both ruff and mypy exclude the generated file, so a formatter cannot perturb the byte comparison. Worth noting for whoever checks next: the ruleset list contains only "CLA Check" — the real gate is classic branch protection, so checking rulesets alone yields a false "unguarded" reading.test_auth_headers.pyasserts the header actually sent rather than the 401/200 outcome, andtest_transport_security.py/test_follow_up_links.py/test_content_redirect_security.pycover cross-origin bearer-token non-leak.queue_full→Retry-After→ retry → success, plus budget exhaustion, sync and async.data:; integration-level covers reconnect-without-replay, the zombie stalled connection, and the 501 no-SSE surface.monkeypatch.setattrcalls target module constants, not the code under test.tests/integration/is correctlyskipif-gated.@christian-byrne commented on GitHub (Aug 21, 2026):
Third defect behind the same untested contract:
submit()gives up on a retryable 429Found while auditing
comfy-typescript-sdkagainst this issue's findings — the same gap is in bothSDKs, so I'm adding it here rather than opening a second issue.
submit()retries by error code, but the contract disambiguates 429 by status +Retry-After.The SDK's own generated model says so:
The retry loop gates on the translated type:
and
QueueFullis produced for exactly one code:Why this is easy to dismiss, and isn't
_CODE_BY_STATUSmaps429: "queue_full"(errors.py:140), so a first read suggests every 429retries. It doesn't — that table is a fallback, reached only when the envelope carries no code:
So the behaviour splits on whether the server bothered to name the code:
submit()queue_full{"error": {"code": "deployment_not_ready"}}deployment_not_ready→ComfyErrorA deployment that is merely warming up — the one case the contract explicitly calls out as
retryable — is the case that fails, and it fails on a serverless cold start, which is exactly when a
caller least expects a hard error.
_QUEUE_RETRY_BUDGETis never consulted.Fix
retry_afteris already parsed off the header (transport.py:76) and carried on theApiError, sothe information needed is present at the branch — it's the predicate that's wrong. Gate on the
status and the header rather than the code:
Both paths need it — sync
client.py:171and asyncclient.py:254.That also makes the behaviour forward-compatible: any future 429 code the server adds becomes
retryable without an SDK release, which is what "disambiguated by
error.codealone" is asking for.Test
Fits the contract-test table this issue already proposes. The case that matters is a 429 whose
envelope names a code other than
queue_full—deployment_not_readyis the one in the spec.A test that only exercises a bare 429 passes today and proves nothing, which is presumably how this
survived.
Same defect exists in
comfy-typescript-sdk(filed as Comfy-Org/comfy-typescript-sdk#62) — worthfixing in one pass so the two SDKs don't drift further.