-
Notifications
You must be signed in to change notification settings - Fork 6
fix(errors): drop 409 from the status->code fallback so a code-less 409 raises ComfyError #132
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -180,11 +180,14 @@ def error_from_envelope( | |
| ``invalid_input`` into ``invalid_workflow`` (failing every reachability | ||
| probe), and its ``403`` ``not_enabled`` into ``forbidden`` (so ``except | ||
| NotEnabled`` — the one handler every pre-launch caller writes — never | ||
| fired). Retyping only happens on responses that carry a bucket, which only | ||
| Router sends, and there the bucket IS the truth; a v2 envelope carries | ||
| ``error.code`` and no top-level ``error_type``, and an intermediary's | ||
| reject carries neither, so both keep exactly the classes integrators | ||
| already catch. | ||
| fired). ``409`` has since been dropped from the table outright (see the | ||
| admission rule on :data:`_CODE_BY_STATUS`), so the first of those three no | ||
| longer needs this ordering as its second line of defence; the ordering | ||
| still decides the other two. Retyping only happens on responses that carry | ||
| a bucket, which only Router sends, and there the bucket IS the truth; a v2 | ||
| envelope carries ``error.code`` and no top-level ``error_type``, and an | ||
| intermediary's reject carries neither, so both keep exactly the classes | ||
| integrators already catch. | ||
| """ | ||
| err = (body or {}).get("error") if isinstance(body, dict) else None | ||
| code = (err or {}).get("code") if isinstance(err, dict) else None | ||
|
|
@@ -216,12 +219,37 @@ def error_from_envelope( | |
| ) | ||
|
|
||
|
|
||
| #: The last resort: a code guessed from the status, consulted only when the | ||
| #: response named none of its own — no envelope ``error.code``, no Router | ||
| #: bucket. That is exactly the population this table must be sized for, and it | ||
| #: is not the compliant surface: the producers of a code-less body are Router | ||
| #: (whose error body is ``{detail, error_type}``, with no ``error.code``) and | ||
| #: the intermediaries between the caller and either surface, and neither is | ||
| #: bound by what any route documents for the status. | ||
| #: | ||
| #: **Admission rule for a new status: only a status with ONE meaning across | ||
| #: every documented surface gets a typed guess.** A status the contract itself | ||
| #: spells two ways cannot be guessed, because the guess is not "unknown, but | ||
| #: roughly this" — it is a class the caller catches and acts on. | ||
| #: | ||
| #: ``409`` is the worked example of exclusion, and it was removed from this | ||
| #: table for that reason: the contract uses it for both ``hash_mismatch`` | ||
| #: (``POST /assets``) and ``asset_in_use`` (``DELETE /assets/{id}``), so even | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟢 Low — |
||
| #: on the compliant surface the status alone does not say which. A code-less | ||
| #: ``409`` therefore stays a bare ``ApiError`` carrying the real status, and | ||
| #: surfaces to the caller as a plain ``ComfyError``. A real hash mismatch is | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟢 Low — The guarantee that a real hash mismatch "always arrives enveloped" has a hole on the unparseable-body path: the transport sets |
||
| #: unaffected: it always arrives enveloped, and ``error.code`` wins outright. | ||
| #: | ||
| #: ``422`` and ``429`` are kept deliberately, and the rule is what keeps them: | ||
| #: their possible misreadings stay inside the right *action class*. A ``422`` | ||
| #: read as ``invalid_workflow`` is still a terminal refusal to fix the request; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟢 Low — The new admission rule ("only a status with ONE meaning across every documented surface") is not satisfied by the retained |
||
| #: a ``429`` read as ``queue_full`` is still back-off-and-retry. ``HashMismatch`` | ||
| #: is the one that failed the rule — it tells the caller to re-upload bytes. | ||
| _CODE_BY_STATUS: dict[int, str] = { | ||
| 401: "unauthorized", | ||
| 402: "insufficient_credits", | ||
| 403: "forbidden", | ||
| 404: "not_found", | ||
| 409: "hash_mismatch", | ||
| 422: "invalid_workflow", | ||
| 429: "queue_full", | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,15 +1,23 @@ | ||
| """How an error response on the wire becomes a typed exception. | ||
|
|
||
| `to_sdk_error` mapping the server's 404 codes to the typed `NotFound`, and | ||
| `error_from_envelope` reading the two body shapes this API answers in. | ||
| `to_sdk_error` mapping the server's 404 codes to the typed `NotFound`, | ||
| `error_from_envelope` reading the two body shapes this API answers in, and | ||
| which statuses may be decoded to a typed code when the response named none. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import pytest | ||
|
|
||
| from comfy_low.errors import ApiError, QueueFull, error_from_envelope | ||
| from comfy_sdk.exceptions import NotFound, to_sdk_error | ||
| from comfy_low.errors import ( | ||
| ApiError, | ||
| HashMismatch, | ||
| QueueFull, | ||
| Unauthorized, | ||
| error_from_envelope, | ||
| ) | ||
| from comfy_sdk.exceptions import ComfyError, NotFound, to_sdk_error | ||
| from comfy_sdk.exceptions import HashMismatch as SdkHashMismatch | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("code", ["not_found", "job_not_found", "asset_not_found"]) | ||
|
|
@@ -96,6 +104,55 @@ def test_a_bucketless_429_still_means_queue_full() -> None: | |
| assert err.retry_after == 3 | ||
|
|
||
|
|
||
| # --- which statuses the status table may decode, and which it may not --- | ||
| # | ||
| # The table is consulted only for a response that named no code of its own, so | ||
| # it never sees the compliant envelope surface -- it sees Router-shaped bodies | ||
| # and intermediaries, which can answer a status for anything. A typed guess is | ||
| # therefore admissible only for a status with ONE meaning across every | ||
| # documented surface. 409 is not one: the contract itself spells it both | ||
| # `hash_mismatch` (POST /assets) and `asset_in_use` (DELETE /assets/{id}). | ||
|
|
||
|
|
||
| def test_a_bucketless_409_is_not_guessed_to_be_a_hash_mismatch() -> None: | ||
| err = error_from_envelope(409, None) | ||
| assert type(err) is ApiError | ||
| assert not isinstance(err, HashMismatch) | ||
| assert err.code == "error" | ||
| assert err.http_status == 409 | ||
| # `HashMismatch` tells the caller to re-upload bytes, which is why this | ||
| # status cannot be guessed: it is a distinct action, not a vaguer wording | ||
| # of the same one. | ||
| sdk_err = to_sdk_error(err) | ||
| assert type(sdk_err) is ComfyError | ||
| assert not isinstance(sdk_err, SdkHashMismatch) | ||
| assert sdk_err.http_status == 409 | ||
|
Comment on lines
+117
to
+129
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Test response-message preservation for code-less 409 errors. This test passes The PR objective requires preservation of the response message. As per path instructions, tests must exercise the described behavior rather than only asserting current output. 🤖 Prompt for AI AgentsSource: Path instructions |
||
|
|
||
|
|
||
| def test_a_bucketless_409_still_carries_the_pace_the_server_named() -> None: | ||
| # Dropping the code must not drop the header: a conflict that named a | ||
| # `Retry-After` is still telling the caller when to ask again. | ||
| err = error_from_envelope(409, None, retry_after=7) | ||
| assert err.retry_after == 7 | ||
| assert to_sdk_error(err).retry_after == 7 | ||
|
|
||
|
|
||
| def test_an_enveloped_409_is_still_a_hash_mismatch() -> None: | ||
| # The assets path is unaffected: a real hash mismatch always arrives | ||
| # enveloped, and `error.code` wins outright. | ||
| err = error_from_envelope(409, {"error": {"code": "hash_mismatch", "message": "m"}}) | ||
| assert type(err) is HashMismatch | ||
| assert err.code == "hash_mismatch" | ||
| assert type(to_sdk_error(err)) is SdkHashMismatch | ||
|
|
||
|
|
||
| def test_a_bodyless_401_still_maps_to_unauthorized() -> None: | ||
| # Only 409 was dropped -- the rest of the table decodes exactly as before. | ||
| err = error_from_envelope(401, None) | ||
| assert err.code == "unauthorized" | ||
| assert isinstance(err, Unauthorized) | ||
|
|
||
|
|
||
| def test_a_router_validation_body_degrades_rather_than_coercing_its_detail() -> None: | ||
| # Router's per-field validation body is the FastAPI `detail[]` shape. A | ||
| # list is not a message: stringifying it would put a Python repr in front of | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟢 Low — The comment says the table is consulted whenever the response named no code of its own, but an empty or whitespace
error.codedefeats that:code = (err or {}).get("code")is never passed through_cleanand both guards below testif code is None, so a body like{"error": {"code": ""}}short-circuits the Router-bucket fallback and this table — a bare 401 would yieldApiError(code="")instead ofUnauthorized. The message already uses a falsy check (if not message) and botherror_typesources go through_clean, soerror.codeis the one field where a malformed value survives. Raised by 1 of 6 reviewers (claude-opus-5-thinking-max edge-case).