[PR #154] [MERGED] fix(errors): surface a Router 422's detail[] on RouterError.errors from models.run #155

Closed
opened 2026-10-31 22:01:51 +00:00 by bsakaguchi · 0 comments
Owner

📋 Pull Request Information

Original PR: https://github.com/Comfy-Org/comfy-python-sdk/pull/154
Author: @mattmillerai
Created: 9/15/2026
Status: ✅ Merged
Merged: 9/18/2026
Merged by: @mattmillerai

Base: main ← Head: matt/be-14332-router-422-detail


📝 Commits (1)

  • 6fe83a4 fix(errors): surface a Router 422's detail[] on RouterError.errors from models.run

📊 Changes

6 files changed (+276 additions, -5 deletions)

View changed files

📝 CHANGELOG.md (+6 -0)
📝 src/comfy_low/errors.py (+56 -1)
📝 src/comfy_sdk/exceptions.py (+16 -0)
📝 tests/conftest.py (+21 -0)
📝 tests/test_error_mapping.py (+64 -4)
📝 tests/test_models_run.py (+113 -0)

📄 Description

ELI-5

When a model says "your steps is too big and your model name is unknown", Comfy Router answers with a list — one entry per field it objected to. The SDK promises those land on RouterError.errors so you can branch on them. On the queued surface they did. On models.run they did not: the list fell through a helper that only accepts strings, so you got InvalidInput(detail='HTTP 422: {"detail":[…]}', errors=()) — the reasons pasted into the message as raw JSON, and nothing to read as data. This makes run behave like the queued path.

What changed

The layering rule shapes the fix: comfy_low may not import comfy_sdk (pinned by tests/test_error_contract.py), and ValidationErrorDetail lives in comfy_sdk. So the entries are carried up raw and converted at the translation boundary.

  • comfy_low/errors.py — ApiError gains validation_errors: the raw, decoded detail[] entries that are mappings, in wire order, empty everywhere else. error_from_envelope now recognises the array form of detail (a non-str/bytes Sequence), collects those entries, and — only when nothing else supplied a message — uses their msg values joined with "; " as the message. Because a message is now found, the existing "the excerpt is kept only where the body stated no message" rule drops the raw-JSON body_excerpt, so it stops being glued onto str(exc).
  • comfy_sdk/exceptions.py — to_sdk_error passes errors=tuple(_detail_from(e) for e in exc.validation_errors) on the RouterError branch, importing _detail_from lazily for the same reason _router_only_class imports _BY_ERROR_TYPE lazily (router_exceptions subclasses ComfyError from this module).
  • Tests — the existing error_from_envelope case is flipped to assert the entries are read (its original intent, "no Python repr in the message", is re-asserted as "[" not in err.message); new low-layer cases cover an entries-state-no-message body, a string detail, and an error.message that outranks the array; new end-to-end models.run cases (sync + async) assert .errors, .loc, .msg, .ctx, .input, .detail, .error_type, .http_status and the raw entries on __cause__; plus a non-mapping-members case and a test that the awaited and queued paths produce identical .errors for one wire body.
  • tests/conftest.py — the stub server gains model_run_validation_detail, which answers the run with Router's validation shape ({"detail": [...]}, bucket on the header, no error_type in the body — the one Router error body that carries no bucket of its own).

Chesterton's fence: this does change existing behaviour — a list detail used to produce HTTP <status> plus an excerpt. The excerpt rule itself is untouched and is what makes the change consistent: an excerpt is dropped exactly when the body stated a message, which it now does. The error.message → Router detail → status precedence is unchanged, and there is a test for each rung.

Deliberate judgment calls

  • The joined message is the entries' msg values only, not location: msg. router_exceptions._summarise (the queued path) renders body.steps: too large; replicating that in comfy_low would mean duplicating the location-formatting rule across the layer boundary. .errors[i].loc carries the location as data on both paths and a test pins that the two paths produce identical .errors. Consequence, stated plainly: the detail string now differs in format between the awaited and queued surfaces. See Residual.
  • _detail_from kept private rather than promoted to detail_from; the cross-module private import matches the _BY_ERROR_TYPE precedent two functions above it, and nothing outside src/ uses either name.
  • The end-to-end tests use the repo's stub server, not httpx.MockTransport. There is no MockTransport usage anywhere in this repo, and the stub exercises the real comfy_low transport, so the assertion covers the actual decode path rather than a hand-fed body.

Verification

uv run --extra dev pytest — 874 passed, 4 skipped. uv run --extra dev mypy src — no issues in 20 files. uv run --extra dev ruff check . / ruff format --check . — clean. uv run --extra codegen python scripts/check_drift.py — models and all 15 router error types in sync. python3 scripts/check_public_repo_hygiene.py — clean. (The four gates AGENTS.md names, plus the lint/format pair.)

The pre-fix behaviour was reproduced on this branch before the fix landed, not assumed: the first run of the new non-mapping test failed with assert 'HTTP 422: {"detail": [1, "x"]}' == 'HTTP 422', which is exactly the shape reported for the current default branch.

Residual

Not fixed here, and each is actionable on its own:

  • .errors is still empty when a detail[] arrives on a non-RouterError class. to_sdk_error passes errors= only on the RouterError branch, because ComfyError, QueueFull and the _BY_CODE classes take no such parameter. Two real bodies reach that branch: a 422 carrying detail[] but no X-Comfy-Error-Type header (the status table guesses invalid_workflow → InvalidWorkflow), and a 429 bucketed queue_full. Both now get the entries' messages as the message, but the typed entries are dropped. Fixing it means either an errors attribute on ComfyError or a wider retype, which is a public-surface decision rather than a bug fix.
  • The awaited and queued paths format the detail string differently for the same wire body. run gives too large; unknown model variant; the queued path's _summarise gives body.steps: too large; body.model: unknown model variant. .errors is identical on both (asserted). Converging the two means either lifting the location formatting into comfy_low or moving the summary up to the translation boundary.
  • router_exceptions.error_from_response still has zero callers in src/. It reads this same wire shape a third time, alongside error_from_envelope and error_from_completion. This PR did not consolidate them — it would touch the retry and transport paths as well.
  • The joined message is unbounded, server-controlled text. It is neither length-capped nor control-character-filtered, unlike body_excerpt (clean_body_excerpt) and request_id (clean_request_id). This is not new — a string detail has always reached str(exc) the same way — but the array form now brings N messages instead of one, so a hardening pass over "message text that reaches a log line" would be worth its own change.
  • Nothing was exercised against a live Router. Every assertion here runs against the vendored contract (spec/router-openapi.yaml, which documents RouterValidationErrorDetail and the header-only bucket on the 422) and the stub server built to match it. The measurement this work is based on was made elsewhere and is reproduced above from the pre-fix failure, not re-measured against a real deployment.
  • The follow-up work this unblocks was not done here. The originating plan asks for a reference to this merge to be recorded for a downstream pin; I hold no access to the tracker to record it, so that hand-off is unperformed.

Provenance

  • Authored by: agent-work loop
  • Verified: uv run --extra dev pytest: 874 passed, 4 skipped; uv run --extra dev mypy src: no issues in 20 source files; uv run --extra dev ruff check . and ruff format --check .: clean; uv run --extra codegen python scripts/check_drift.py: models and all 15 router error types in sync; python3 scripts/check_public_repo_hygiene.py: clean
  • Deviations: three, all under "Deliberate judgment calls" above — the joined message uses msg only rather than the queued path's location: msg format; _detail_from was left private; the end-to-end tests drive the repo's stub server rather than httpx.MockTransport. One planned assertion was corrected rather than implemented: for a detail array whose members are not objects, the plan expected detail == "HTTP 422", but the pre-existing body-excerpt rule keeps the excerpt precisely when the body stated no message, so the real value is HTTP 422: {"detail": [1, "x"]} and the test asserts that instead of changing the excerpt rule.

Summary by CodeRabbit

  • Bug Fixes

    • Improved handling of model-run validation failures with field-level error details.
    • Error messages now show validation messages instead of a generic HTTP 422 message when available.
    • Validation details are preserved for clearer troubleshooting.
    • Synchronous, asynchronous, queued, and runtime model-run errors now provide consistent information.
    • Existing handling remains available for responses without structured validation details.
  • Documentation

    • Added an Unreleased changelog entry describing the improved validation-error reporting.

🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.

## 📋 Pull Request Information **Original PR:** https://github.com/Comfy-Org/comfy-python-sdk/pull/154 **Author:** [@mattmillerai](https://github.com/mattmillerai) **Created:** 9/15/2026 **Status:** ✅ Merged **Merged:** 9/18/2026 **Merged by:** [@mattmillerai](https://github.com/mattmillerai) **Base:** `main` ← **Head:** `matt/be-14332-router-422-detail` --- ### 📝 Commits (1) - [`6fe83a4`](https://github.com/Comfy-Org/comfy-python-sdk/commit/6fe83a4ee158dd2aa666db63d51e48322ae3fb22) fix(errors): surface a Router 422's detail[] on RouterError.errors from models.run ### 📊 Changes **6 files changed** (+276 additions, -5 deletions) <details> <summary>View changed files</summary> 📝 `CHANGELOG.md` (+6 -0) 📝 `src/comfy_low/errors.py` (+56 -1) 📝 `src/comfy_sdk/exceptions.py` (+16 -0) 📝 `tests/conftest.py` (+21 -0) 📝 `tests/test_error_mapping.py` (+64 -4) 📝 `tests/test_models_run.py` (+113 -0) </details> ### 📄 Description ## ELI-5 When a model says "your `steps` is too big and your `model` name is unknown", Comfy Router answers with a list — one entry per field it objected to. The SDK promises those land on `RouterError.errors` so you can branch on them. On the queued surface they did. On `models.run` they did not: the list fell through a helper that only accepts strings, so you got `InvalidInput(detail='HTTP 422: {"detail":[…]}', errors=())` — the reasons pasted into the message as raw JSON, and nothing to read as data. This makes `run` behave like the queued path. ## What changed The layering rule shapes the fix: `comfy_low` may not import `comfy_sdk` (pinned by `tests/test_error_contract.py`), and `ValidationErrorDetail` lives in `comfy_sdk`. So the entries are carried up raw and converted at the translation boundary. - **`comfy_low/errors.py`** — `ApiError` gains `validation_errors`: the raw, decoded `detail[]` entries that are mappings, in wire order, empty everywhere else. `error_from_envelope` now recognises the array form of `detail` (a non-`str`/`bytes` `Sequence`), collects those entries, and — only when nothing else supplied a message — uses their `msg` values joined with `"; "` as the message. Because a message is now found, the existing "the excerpt is kept only where the body stated no message" rule drops the raw-JSON `body_excerpt`, so it stops being glued onto `str(exc)`. - **`comfy_sdk/exceptions.py`** — `to_sdk_error` passes `errors=tuple(_detail_from(e) for e in exc.validation_errors)` on the `RouterError` branch, importing `_detail_from` lazily for the same reason `_router_only_class` imports `_BY_ERROR_TYPE` lazily (`router_exceptions` subclasses `ComfyError` from this module). - **Tests** — the existing `error_from_envelope` case is flipped to assert the entries are read (its original intent, "no Python repr in the message", is re-asserted as `"[" not in err.message`); new low-layer cases cover an entries-state-no-message body, a string `detail`, and an `error.message` that outranks the array; new end-to-end `models.run` cases (sync + async) assert `.errors`, `.loc`, `.msg`, `.ctx`, `.input`, `.detail`, `.error_type`, `.http_status` and the raw entries on `__cause__`; plus a non-mapping-members case and a test that the awaited and queued paths produce identical `.errors` for one wire body. - **`tests/conftest.py`** — the stub server gains `model_run_validation_detail`, which answers the run with Router's validation shape (`{"detail": [...]}`, bucket on the header, no `error_type` in the body — the one Router error body that carries no bucket of its own). Chesterton's fence: this does change existing behaviour — a list `detail` used to produce `HTTP <status>` plus an excerpt. The excerpt rule itself is untouched and is what makes the change consistent: an excerpt is dropped exactly when the body stated a message, which it now does. The `error.message` → Router `detail` → status precedence is unchanged, and there is a test for each rung. ## Deliberate judgment calls - **The joined message is the entries' `msg` values only, not `location: msg`.** `router_exceptions._summarise` (the queued path) renders `body.steps: too large`; replicating that in `comfy_low` would mean duplicating the location-formatting rule across the layer boundary. `.errors[i].loc` carries the location as data on both paths and a test pins that the two paths produce identical `.errors`. Consequence, stated plainly: the `detail` **string** now differs in format between the awaited and queued surfaces. See Residual. - **`_detail_from` kept private** rather than promoted to `detail_from`; the cross-module private import matches the `_BY_ERROR_TYPE` precedent two functions above it, and nothing outside `src/` uses either name. - **The end-to-end tests use the repo's stub server, not `httpx.MockTransport`.** There is no `MockTransport` usage anywhere in this repo, and the stub exercises the real `comfy_low` transport, so the assertion covers the actual decode path rather than a hand-fed body. ## Verification `uv run --extra dev pytest` — 874 passed, 4 skipped. `uv run --extra dev mypy src` — no issues in 20 files. `uv run --extra dev ruff check .` / `ruff format --check .` — clean. `uv run --extra codegen python scripts/check_drift.py` — models and all 15 router error types in sync. `python3 scripts/check_public_repo_hygiene.py` — clean. (The four gates `AGENTS.md` names, plus the lint/format pair.) The pre-fix behaviour was reproduced on this branch before the fix landed, not assumed: the first run of the new non-mapping test failed with `assert 'HTTP 422: {"detail": [1, "x"]}' == 'HTTP 422'`, which is exactly the shape reported for the current default branch. ## Residual Not fixed here, and each is actionable on its own: - **`.errors` is still empty when a `detail[]` arrives on a non-`RouterError` class.** `to_sdk_error` passes `errors=` only on the `RouterError` branch, because `ComfyError`, `QueueFull` and the `_BY_CODE` classes take no such parameter. Two real bodies reach that branch: a `422` carrying `detail[]` but **no** `X-Comfy-Error-Type` header (the status table guesses `invalid_workflow` → `InvalidWorkflow`), and a `429` bucketed `queue_full`. Both now get the entries' messages as the message, but the typed entries are dropped. Fixing it means either an `errors` attribute on `ComfyError` or a wider retype, which is a public-surface decision rather than a bug fix. - **The awaited and queued paths format the `detail` string differently for the same wire body.** `run` gives `too large; unknown model variant`; the queued path's `_summarise` gives `body.steps: too large; body.model: unknown model variant`. `.errors` is identical on both (asserted). Converging the two means either lifting the location formatting into `comfy_low` or moving the summary up to the translation boundary. - **`router_exceptions.error_from_response` still has zero callers in `src/`.** It reads this same wire shape a third time, alongside `error_from_envelope` and `error_from_completion`. This PR did not consolidate them — it would touch the retry and transport paths as well. - **The joined message is unbounded, server-controlled text.** It is neither length-capped nor control-character-filtered, unlike `body_excerpt` (`clean_body_excerpt`) and `request_id` (`clean_request_id`). This is not new — a string `detail` has always reached `str(exc)` the same way — but the array form now brings N messages instead of one, so a hardening pass over "message text that reaches a log line" would be worth its own change. - **Nothing was exercised against a live Router.** Every assertion here runs against the vendored contract (`spec/router-openapi.yaml`, which documents `RouterValidationErrorDetail` and the header-only bucket on the `422`) and the stub server built to match it. The measurement this work is based on was made elsewhere and is reproduced above from the pre-fix failure, not re-measured against a real deployment. - **The follow-up work this unblocks was not done here.** The originating plan asks for a reference to this merge to be recorded for a downstream pin; I hold no access to the tracker to record it, so that hand-off is unperformed. ## Provenance - **Authored by:** agent-work loop - **Verified:** `uv run --extra dev pytest`: 874 passed, 4 skipped; `uv run --extra dev mypy src`: no issues in 20 source files; `uv run --extra dev ruff check .` and `ruff format --check .`: clean; `uv run --extra codegen python scripts/check_drift.py`: models and all 15 router error types in sync; `python3 scripts/check_public_repo_hygiene.py`: clean - **Deviations:** three, all under "Deliberate judgment calls" above — the joined message uses `msg` only rather than the queued path's `location: msg` format; `_detail_from` was left private; the end-to-end tests drive the repo's stub server rather than `httpx.MockTransport`. One planned assertion was corrected rather than implemented: for a `detail` array whose members are not objects, the plan expected `detail == "HTTP 422"`, but the pre-existing body-excerpt rule keeps the excerpt precisely when the body stated no message, so the real value is `HTTP 422: {"detail": [1, "x"]}` and the test asserts that instead of changing the excerpt rule. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **Bug Fixes** - Improved handling of model-run validation failures with field-level error details. - Error messages now show validation messages instead of a generic HTTP 422 message when available. - Validation details are preserved for clearer troubleshooting. - Synchronous, asynchronous, queued, and runtime model-run errors now provide consistent information. - Existing handling remains available for responses without structured validation details. - **Documentation** - Added an Unreleased changelog entry describing the improved validation-error reporting. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --- <sub>🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.</sub>
bsakaguchi 2026-10-31 22:01:51 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Comfy-Org/comfy-python-sdk#155
No description provided.