Summary
A test-coverage audit of origin/main (c96eb09) turned up two verified defects that share one
root 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 raw comfy_low.ApiError
comfy_sdk/exceptions.py::translating documents the contract:
Wrap the SDK-level operations that call comfy_low with this so integrators only ever catch
comfy_sdk exceptions (MissingAsset, HashMismatch, NotFound, ...), never the raw protocol error.
The README's "Typed errors" section makes the same promise: every error is a ComfyError subclass.
These entry points do not wrap, and raise comfy_low.errors.ApiError straight through:
| Entry point |
File |
Output.to_file / to_stream / to_bytes / get_download_url |
src/comfy_sdk/outputs.py — the module never imports translating at all |
AsyncOutput.to_file / to_bytes / get_download_url |
same |
AssetFactory.get / AsyncAssetFactory.get |
src/comfy_sdk/assets.py:271, :322 |
JobFactory.get / AsyncJobFactory.get |
src/comfy_sdk/jobs.py:301, :309 |
Job.events() / AsyncJob.events() — the non-501 raise |
src/comfy_sdk/jobs.py:516, :628 |
Why this bites harder than a normal type mismatch: comfy_low.errors and
comfy_sdk.exceptions both export a class named NotFound, and they are unrelated
(ApiError vs ComfyError). So a consumer writing the documented
from comfy_sdk import NotFound
try:
out.to_file("result.png")
except NotFound:
...
silently never catches. And for the entity-specific server codes (job_not_found,
asset_not_found), comfy_low._BY_CODE has no entry either, so what escapes is a bare
comfy_low.errors.ApiError — not even the protocol-level NotFound.
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 — two
entry points that are wrapped, on the same code paths, to show the harness detects correct
behaviour:
import os, sys
sys.path.insert(0, "tests")
import conftest as C
srv = C._start_server()
os.environ[C.BASE_URL_ENV_VAR] = srv.base_url
from comfy_sdk import Comfy
import comfy_sdk.exceptions as sdkx
import comfy_low.errors as lowx
client = Comfy()
def probe(label, fn):
try:
fn(); print(f" {label:46s} no error")
except sdkx.ComfyError as e:
print(f" {label:46s} comfy_sdk.{type(e).__name__} OK")
except lowx.ApiError as e:
print(f" {label:46s} comfy_low.{type(e).__name__} *** LEAK ***")
wf = client.workflows.from_json({"3": {"class_type": "KSampler", "inputs": {}}})
print("controls (expected: comfy_sdk.*):")
srv.state.job_error = (422, "invalid_workflow")
probe("client.submit(wf) [wrapped]", lambda: client.submit(wf))
srv.state.job_error = None
srv.state.job_workflow_not_found = True
job = client.submit(wf)
probe("job.get_workflow() [wrapped]", lambda: job.get_workflow())
srv.state.job_workflow_not_found = False
print("suspects:")
client.assets.delete("asset_out_01") # stub 404s a deleted asset thereafter
probe("client.assets.get(deleted id)", lambda: client.assets.get("asset_out_01"))
out = client.submit(wf).result().get_outputs("13")[0]
import io
probe("output.to_bytes() (deleted asset)", lambda: out.to_bytes())
probe("output.to_file(...) (deleted asset)", lambda: out.to_file("/dev/null"))
probe("output.to_stream(...) (deleted asset)", lambda: out.to_stream(io.BytesIO()))
probe("output.get_download_url() (deleted)", lambda: out.get_download_url())
client.close(); C._stop_server(srv)
Output:
controls (expected: comfy_sdk.*):
client.submit(wf) [wrapped] comfy_sdk.InvalidWorkflow OK
job.get_workflow() [wrapped] comfy_sdk.NotFound OK
suspects:
client.assets.get(deleted id) comfy_low.NotFound *** LEAK ***
output.to_bytes() (deleted asset) comfy_low.NotFound *** LEAK ***
output.to_file(...) (deleted asset) comfy_low.NotFound *** LEAK ***
output.to_stream(...) (deleted asset) comfy_low.NotFound *** LEAK ***
output.get_download_url() (deleted) comfy_low.NotFound *** LEAK ***
JobFactory.get needs a server that 404s a job (the stub returns a job for any id); against a
minimal 404-everything handler it leaks the same way:
client.jobs.get(id) -> comfy_low.ApiError *** LEAKED ***
client.assets.get(id) -> comfy_low.ApiError *** LEAKED ***
Suggested fix
Wrap the four outputs.py download methods (sync + async) and both factories' get() in
with translating():, and translate before the raise in events(). Then add the test that
would have caught it — one that asserts no comfy_low.ApiError escapes any public entry point.
Defect 2 — Output.to_stream has no AsyncOutput counterpart, and no test
src/comfy_sdk/outputs.py:89 defines Output.to_stream(...). AsyncOutput has no to_stream.
The README states:
Comfy and AsyncComfy expose the identical surface — swap the import and add await / async for.
and closes the downloads section with "(AsyncOutput mirrors all of the above with await.)".
A public-surface diff over every sync/async pair — 6 of 7 are identical, one is not:
Comfy /AsyncComfy sync-only=[] async-only=[]
Asset /AsyncAsset sync-only=[] async-only=[]
AssetFactory /AsyncAssetFactory sync-only=[] async-only=[]
Job /AsyncJob sync-only=[] async-only=[]
JobFactory /AsyncJobFactory sync-only=[] async-only=[]
Output /AsyncOutput sync-only=['to_stream'] async-only=[] <-- ASYMMETRY
ComfyLow /AsyncComfyLow sync-only=[] async-only=[]
(the check normalises the intentional close/aclose rename)
to_stream is also the only download method with zero test references anywhere in tests/ —
which is why the asymmetry went unnoticed. Fix is async def to_stream on AsyncOutput plus a
parity 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.
What was checked and is clean
Recording these so nobody re-runs them:
- Codegen drift is guarded and enforced.
scripts/check_drift.py regenerates into a tempdir and compares byte-for-byte, so both a spec edit without a regen and a hand-edit of _generated.py are caught. The comfy_low codegen drift job runs it on every PR and push, and it is a required status check on main (verified at repos/.../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.
- Auth is unusually well covered —
test_auth_headers.py asserts the header actually sent rather than the 401/200 outcome, and test_transport_security.py / test_follow_up_links.py / test_content_redirect_security.py cover cross-origin bearer-token non-leak.
- Retry:
queue_full → Retry-After → retry → success, plus budget exhaustion, sync and async.
- Pagination: N/A, not a gap — the vendored contract has 11 operations and no list endpoint.
- SSE: decoder unit tests cover comment/keepalive, stray blank line, malformed JSON, non-object JSON, multi-line
data:; integration-level covers reconnect-without-replay, the zombie stalled connection, and the 501 no-SSE surface.
- No mock-only assertions. The suite drives a real stdlib HTTP stub; the only two
monkeypatch.setattr calls target module constants, not the code under test.
- Assertion density: all 22 test files have assertions; no file reports as covered while asserting nothing.
tests/integration/ is correctly skipif-gated.
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.