Skip to content

feat(errors): expose refusal_subject on RouterError / ContentPolicyViolation - #192

Open
mattmillerai wants to merge 2 commits into
mainfrom
matt/be-17175-refusal-subject
Open

mattmillerai wants to merge 2 commits into
mainfrom
matt/be-17175-refusal-subject

Conversation

@mattmillerai

@mattmillerai mattmillerai commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

ELI-5

When the Router refuses a model call on content-policy grounds, the SDK raised ContentPolicyViolation but could not tell you what was refused — your prompt, your image, or the model's output. The Router can now say which one (input_image, output_text, ...). This PR reads that and puts it on the exception as exc.refusal_subject.

What changed

  • RouterError.__init__ gains refusal_subject: str | None = None, stored as .refusal_subject. It is never narrowed to the documented list, the same policy error_type follows, so a subject added after this version was built still reaches the caller. It is bounded, though: every read goes through a shared comfy_low.errors.clean_refusal_subject, which full-matches one token of [A-Za-z0-9_.-]{1,64}. A duplicated header (which httpx joins as "a, b"), control or bidi characters, or an overlong value reads as None instead of reaching a log line verbatim. Same name as the TypeScript SDK's refusalSubject / refusal_subject.
  • New module constants in comfy_sdk.router_exceptions: REFUSAL_SUBJECT_HEADER = "X-Comfy-Refusal-Subject" and REFUSAL_SUBJECTS, which lists the ten documented values (input, output, input_{text,image,video,audio}, output_{text,image,video,audio}). Both are added to __all__.
  • error_from_response reads the header first (case-insensitively; a blank or malformed value counts as absent) and falls back to body["refusal_subject"]. A non-string body value becomes None.
  • The awaited models.run path does not go through error_from_response. It goes transport → comfy_low.ApiError → comfy_sdk.exceptions.to_sdk_error. If only the factory had changed, exc.refusal_subject would always be None on the main call path. So ApiError and error_from_envelope now also accept refusal_subject, using the same header-then-body order. The transport passes the header, and to_sdk_error forwards the value onto the RouterError.
  • error_from_completion (the queued surface) reads refusal_subject from the completed payload only. The poll and result responses do have headers, but only the body reaches this function, so a subject sent only on the header reads as None on the queued path.
  • Test stub: two new ServerState knobs, model_run_error_headers and model_run_error_body_extra, for Router-shaped failures.
  • CHANGELOG [Unreleased] / Added entry.

Tests cover: header wins over body; body only; absent; blank header falls back to body; non-string body value; unknown value passes through unchanged (header and body); malformed values (duplicated, ANSI, newline, bidi, overlong) read as None on the header, body and completion surfaces, and a malformed header falls back to the body; the 422 detail[] validation shape never sets it; the queued completion; and full sync and async models.run runs against the stub server (header-wins, body-only, absent, hostile-header). A spec tripwire, test_the_refusal_subject_stays_undeclared_until_someone_reconciles_it, fails once a sync mentions the header or field name.

Judgment calls

  • The ticket asked for the "response → exception factory" only. I also covered the models.run path and the queued completion, for the reason given above.
  • The attribute is on the base RouterError, as the ticket asked. It is not limited to ContentPolicyViolation. If the Router ever sends the header with another bucket, the value passes through rather than being dropped.

Provenance

  • Authored by: agent-work loop
  • Verified: at 9db6df3 (rebased onto main 9d0d6aa): ruff check .: all checks passed; ruff format --check .: 60 files already formatted; mypy src: no issues in 22 source files; pytest: 1162 passed, 9 skipped; scripts/check_drift.py: OK; scripts/check_public_repo_hygiene.py: OK. PR CI at 9db6df3 is green on every check.
  • Deviations: none against the ticket's listed changes. The scope is wider than the ticket's "factory only" wording, as described under Judgment calls. After review, the value is bounded to one printable token rather than passed through raw.

Residual

  • The vendored spec/router-openapi.yaml does not declare refusal_subject or X-Comfy-Refusal-Subject yet. The field name, the header name and the ten values come from the upstream Router change described to this work, not from a contract in this repo. When the spec sync that adds them lands, check three things: the header spelling, the body field name, and that the enum matches REFUSAL_SUBJECTS in the same order. test_the_refusal_subject_stays_undeclared_until_someone_reconciles_it fails at that sync so this can't be missed. Replace it with a real pin then.
  • TypeScript SDK parity was not checked. The name follows the stated cross-SDK rule (refusalSubject / refusal_subject), but the TypeScript SDK's routerErrors.ts was not opened.
  • Not tested against a live Router. The integration e2e suite skips without credentials, so the header/body behaviour was tested only against the in-repo stub server.

Summary by CodeRabbit

  • New Features
    • Router errors now include an optional refusal subject, taken from the response header or error body. A header value takes precedence, and unrecognized string values are preserved.
    • Refusal subjects are available for failed model runs and refused queued completions. If neither source provides a usable value, the field is None.
    • Added constants describing the refusal-subject header and documented subjects.

@mattmillerai mattmillerai added the agent-coded Authored by the agent-work loop label Sep 25, 2026
@mattmillerai
mattmillerai marked this pull request as ready for review September 25, 2026 17:44
@coderabbitai

coderabbitai Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: Comfy-Org/comfy-python-sdk/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Team
  • Run ID: c69ad8b6-950a-4799-80bf-eb8aaf11c51c
📥 Commits

Reviewing files that changed from the base of the PR and between 0fbd7ce and 9db6df3.

📒 Files selected for processing (7)
  • CHANGELOG.md
  • src/comfy_low/errors.py
  • src/comfy_low/transport.py
  • src/comfy_sdk/router_exceptions.py
  • tests/conftest.py
  • tests/test_router_exceptions.py
  • tests/test_router_spec_contract.py

Included review availability: This review used your included allowance. 0 included reviews remain after this review. Your included PR review attempts over the past 7 days set your current allowance at 1 review per hour. Your free on-demand review promotion remains active until October 9, 2026 at 6:00 PM UTC.


📝 Walkthrough

Walkthrough

Protocol and Router errors now carry an optional refusal subject. Error conversion reads the value from the response header or body, applies validation, and passes it through SDK error conversion.

Changes

Refusal subject handling

Layer / File(s) Summary
Protocol error propagation
src/comfy_low/errors.py, src/comfy_low/transport.py, src/comfy_sdk/exceptions.py
Protocol errors clean and resolve the refusal subject, then pass it through transport and SDK error conversion.
Router refusal subject contract
src/comfy_sdk/router_exceptions.py, CHANGELOG.md
RouterError stores an optional refusal subject. Response errors prefer the header and fall back to the body; completion errors read the body. The module exports the header constant and documented subject values.
Propagation and validation tests
tests/conftest.py, tests/test_router_exceptions.py, tests/test_router_spec_contract.py
The test server can add refusal-subject headers and body fields to Router-shaped model-run failures. Tests cover fallback, invalid and unknown values, completion conversion, model-run errors, and the vendored spec contract.

Priority: ⬇️ Low

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  participant HTTPResponse
  participant transport
  participant error_from_envelope
  participant ApiError
  participant to_sdk_error
  participant RouterError
  HTTPResponse->>transport: error response and refusal-subject header
  transport->>error_from_envelope: status, body, and header value
  error_from_envelope->>ApiError: selected refusal subject
  ApiError->>to_sdk_error: protocol error with refusal subject
  to_sdk_error->>RouterError: refusal subject
Loading

Suggested reviewers: deepme987

Merge Risk: ⚪ Minimal · up to 9db6d

No additional merge-blocking issue is established by this review. Previously published findings remain for the owner to consider.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 31.25% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 32 functions across 7 files. (1 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main change: exposing refusal_subject on RouterError and ContentPolicyViolation.
Full details: Docstring Coverage

Explanation

Docstring coverage is 31.25% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 32 functions across 7 files. (1 skipped: 1 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

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

@mattmillerai
mattmillerai requested review from a team as code owners September 25, 2026 17:44
@mattmillerai mattmillerai added the cursor-review Request an automated Cursor review label Sep 25, 2026
coderabbitai[bot]
coderabbitai Bot previously approved these changes Sep 25, 2026

@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: 6/6 reviewers contributed findings.

Comment thread src/comfy_sdk/router_exceptions.py Outdated
Comment thread src/comfy_sdk/router_exceptions.py
Comment thread src/comfy_sdk/router_exceptions.py Outdated
Comment thread src/comfy_sdk/router_exceptions.py Outdated

@wei-hai wei-hai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed bounded refusal-subject parsing and propagation through synchronous, asynchronous and completion errors; no blocking findings.

@mattmillerai mattmillerai added the full-autonomy Approved AI-brownfield: merges on machine gates alone, no human approver. Design doc + flag req'd. label Oct 3, 2026
…olation

Read which input or output a content-policy refusal was about from the
X-Comfy-Refusal-Subject header, falling back to the body's
refusal_subject, and carry it as the raw wire value on
RouterError.refusal_subject. REFUSAL_SUBJECTS lists the ten documented
values without narrowing to them.

Threaded through every surface that builds a RouterError:
error_from_response, error_from_completion, and the awaited models.run
path (transport -> ApiError -> to_sdk_error).
Address review: the header/body value is server-controlled and lands on a
displayed attribute, so read it through a shared clean_refusal_subject
(full-match, <=64 chars of [A-Za-z0-9_.-]) on both error surfaces. A
duplicated header joined by httpx, control/bidi characters or an overlong
value reads as undisclosed instead of being logged verbatim.

Also: say REFUSAL_SUBJECT_HEADER is 'typically one of' REFUSAL_SUBJECTS,
correct the queued-path comment about headers, and add a spec tripwire so
a sync that declares the subject forces REFUSAL_SUBJECTS to be reconciled.
@mattmillerai
mattmillerai force-pushed the matt/be-17175-refusal-subject branch from 4de4c34 to 9db6df3 Compare October 3, 2026 03:25

@robinjhuang robinjhuang left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Auto-approved under the full-autonomy policy.

Gates verified at 9db6df337fb3d1110b5cf70e7e93ec3a07e260fe:

  • full-autonomy label present
  • assigned to, or review requested from, @robinjhuang
  • not a draft
  • 8 required check(s) green — none failing, none pending

This approval attests
that the machine gates above passed at this commit. It does not attest that a
human read the diff.

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 full-autonomy Approved AI-brownfield: merges on machine gates alone, no human approver. Design doc + flag req'd.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants