Skip to content

Public error contract is untested, and two defects hide behind it: translating() skipped on all download paths, AsyncOutput.to_stream missing #62

Description

@christian-byrne

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.

  • A test asserting the translating() guarantee across every public entry point — the one that catches Defect 1 and any future regression of it.
  • A sync/async parity test over the 7 class pairs — the one that catches Defect 2.
  • AssetFactory.from_url / AsyncAssetFactory.from_url — zero tests. It is one of four README-documented asset constructors and the only one that opens its own httpx client, so it silently ignores the client's timeout= and client_info.
  • Preview.to_pil() — zero tests. README shows show(pv.to_pil()) and the repo ships a pil extra for it; Pillow is already in [dev].
  • comfy_low.errors.error_from_envelope — zero direct tests, and the _CODE_BY_STATUS status-derived fallback is entirely dark. tests/conftest.py::_err always 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-hosted comfy-api-proxy deployment the README targets.
  • to_sdk_error — 3 of 13 code mappings tested. insufficient_credits, forbidden, blob_not_found appear nowhere in tests/. The table is currently complete, so this is a guard gap rather than a live bug — worth closing the same way test_spec_coverage.py already closes it for operationIds: assert comfy_low.errors._BY_CODE's code set is fully mapped by comfy_sdk.exceptions.
  • The four escape hatches the README's architecture section promises — raw_request, open, all-headers, per-request timeout/abort — have no contract test. Only SSE timeout=None is exercised.
  • _core.backoff_schedule — zero direct tests; wait() never polls long enough to reach cap.
  • 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:

  • 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_fullRetry-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.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions