Skip to content

fix(events): raise a typed error on the terminal SSE error frame instead of reconnecting - #206

Open
mattmillerai wants to merge 2 commits into
mainfrom
matt/be-19942-sse-error-frame
Open

mattmillerai wants to merge 2 commits into
mainfrom
matt/be-19942-sse-error-frame

Conversation

@mattmillerai

@mattmillerai mattmillerai commented Oct 9, 2026 •

Copy link
Copy Markdown
Member

Summary

The server ends a job's event stream with event: error and an error envelope (credential_expired, forbidden, or job_not_found) when it stops streaming for a reason other than the job finishing. event_from_raw returned None for that frame, so Job.events() / AsyncJob.events() skipped it, saw the stream close without a terminal status, polled GET /jobs/{id}, slept 0.1 s and reconnected. For job_not_found, while GET /jobs/{id} still answers, that loop never ends and keeps using up the concurrent-stream quota. For every code, the caller never learned why the stream ended.

Change

  • comfy_low.errors.sse_error_from_frame(data) (also exported from comfy_low): for the three documented codes it reads the frame's envelope with error_from_envelope, taking the status from the code (credential_expired→401, forbidden→403, job_not_found→404) and keeping code exactly as sent. It returns None for any other code or for a malformed frame (no error, or a non-dict or non-string code). Such a frame isn't known to end the stream for good (a later server might drain or cap a stream's lifetime), so it is left to the existing poll-and-reconnect path instead of being raised as an untyped HTTP 0 error.
  • credential_expired → Unauthorized is now mapped in both comfy_low.errors._BY_CODE and comfy_sdk.exceptions._BY_CODE. The caller gets comfy_sdk.exceptions.Unauthorized with .code == "credential_expired". to_sdk_error keeps .error_type == "unauthorized" (the class's Router bucket) for this alias, so error_type never names a value outside the closed Router set.
  • Both events() bodies check for raw.event == "error" before event_from_raw. For a known code they raise inside the existing try, and for any other code they skip the frame. The existing except ApiError sends it through to_sdk_error because the status is never 501. There is no refresh() and no sleep. Both docstrings are updated.
  • events.py: a comment on case _: explains why error is not an Event subclass.
  • CHANGELOG Unreleased → Fixed entry, plus a short paragraph in the README's Live progress section saying a server-ended stream raises instead of reconnecting.

Judgment calls

  • The ticket asked for the sse_error_from_frame unit test in tests/test_errors.py. That file does not exist, so the tests went into the existing tests/test_error_mapping.py, which already covers error_from_envelope.
  • The ticket's state.poll_count is state.job_poll_count in the stub. The tests get the job from client.submit(...), which does not poll, and record the poll count after that.
  • Each stream test stops after 4 events. Without the fix, the reconnect loop made the test hang instead of fail.
  • I also exported sse_error_from_frame from comfy_low, next to error_from_envelope. The ticket did not ask for this.

Residual

  • The vendored spec/openapi.yaml does not list an error event under getJobEvents' x-sse-events. This PR handles the frame because the server already sends it, but the contract does not document it yet. The fix belongs in the canonical spec, followed by a re-sync. The vendored spec is not edited here (one-way copy).
  • Not run against a live server. The frame shape {"error": {"code", "message"}} and the three codes come from the server-side description of the change. They are exercised only through the stdlib test stub. tests/integration/test_gateway_e2e.py was skipped as usual (no credentials set) and does not cover this path anyway.
  • Async stream cleanup is unchanged. Raising out of async for leaves the low-level async generator for the GC to finalize instead of explicitly calling aclose(). The existing terminal-status return in the same loop already behaves this way, so this is not a regression, but it could be its own small cleanup.

Provenance

  • Authored by: agent-work loop
  • Verified:
    • Tests: all green — 1273 passed, 9 skipped (integration, no credentials)
    • Lint / format / mypy src: clean
    • Codegen drift + Router error-type drift: OK; public-repo hygiene: OK
    • Mutation-tested: the new stream tests fail with the raw.event == "error" check disabled, and the unknown-code fallback tests fail when every error frame is raised
    • Not run locally: the 3.10/3.11/3.13 matrix (ran on 3.12); build-check
  • Deviations: the sse_error_from_frame tests are in tests/test_error_mapping.py, not tests/test_errors.py, which does not exist. Unlike the original ticket, only the three documented codes raise, and any other error frame falls back to polling (review feedback).

🤖 Generated with Claude Code

…ead of reconnecting

The server ends a job's event stream with `event: error` and an error
envelope (credential_expired / forbidden / job_not_found) when it stops
streaming for a reason other than the job finishing. event_from_raw
dropped the frame, so Job.events() and AsyncJob.events() saw the stream
close without a terminal status, polled, and reconnected - for
job_not_found while GET /jobs/{id} still answers, forever.

Both iterators now raise the frame via comfy_low.sse_error_from_frame,
which maps it to Unauthorized / Forbidden / NotFound (code kept
verbatim). credential_expired maps to Unauthorized in both code tables.
@mattmillerai
mattmillerai requested review from a team as code owners October 9, 2026 21:44
@mattmillerai mattmillerai added agent-coded Authored by the agent-work loop cursor-review Request an automated Cursor review labels Oct 9, 2026
@coderabbitai

coderabbitai Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

  • Run on-demand review

This review includes 10 billable files and costs up to $2.50.

  • Ask an admin to make reviews automatic

Open in CodeRabbit

Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing.

Or wait 37 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available. Your 131 included PR review attempts over the past 7 days set your current allowance at 1 review per hour.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Repository: Comfy-Org/comfy-python-sdk/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Team
  • Run ID: b36105b8-d6f5-45d3-93b3-e6879d3e4819

📥 Commits

Reviewing files that changed from the base of the PR and between 93303a1 and 5f9e8fd.


📒 Files selected for processing (10)
  • CHANGELOG.md
  • README.md
  • src/comfy_low/__init__.py
  • src/comfy_low/errors.py
  • src/comfy_sdk/events.py
  • src/comfy_sdk/exceptions.py
  • src/comfy_sdk/jobs.py
  • tests/conftest.py
  • tests/test_error_mapping.py
  • tests/test_events.py


  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Cursor Review — Consolidated panel

Triggered by @mattmillerai.

Found 4 finding(s).

Severity Count
🟡 Medium 1
🟢 Low 2
⚪ Nit 1

Panel: 5/6 reviewers contributed findings.

Reviewers that did not contribute: kimi-k3-max:edge-case (error)

Comment thread src/comfy_sdk/jobs.py Resolved
Comment thread src/comfy_low/errors.py Resolved
Comment thread src/comfy_sdk/exceptions.py Resolved
Comment thread src/comfy_low/errors.py Resolved
…llback

Raise only for the documented terminal frame codes (credential_expired,
forbidden, job_not_found). Any other code, or a frame with no readable
envelope, is treated as the stream ending early, so a future graceful
drain or lifetime cap does not crash a still-running job's iteration
with an untyped HTTP 0 error.

to_sdk_error keeps the Router bucket as error_type for the
credential_expired alias; the wire spelling stays on .code.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent-coded Authored by the agent-work loop cursor-review Request an automated Cursor review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant