fix(#20): heid-code-review fixups — INV-CUT-2 completeness on cancel/stream (slice-2)
Triaged the heid-code-review panel (Gróa + Hulda substantive, Regin zero=weak).
Adopted (genuine adds):
- cancel_turn + stream_turn gain a defensive `except ApiError -> SessionApiFailed`
default after their discriminated branches. INV-CUT-2 ("every ApiError is mapped;
default SessionApiFailed") now holds STRUCTURALLY on those routes, not by coupling
to the SDK's internal guarantee that it maps them to discriminated types. + tests.
- get_session_tools error-path test (symmetric with messages).
- Contract § Error map amended: added the stream ProtocolError rows
(Malformed*/TurnIdFlip -> ratatoskr same-named), clarified the cancel row (the SDK
RAISES the typed races -> ratatoskr exceptions, only a 200/cancelled=False is a
CancelResult; caller surface stays exception-based per DEC-2), and noted the
ApiError default holds on stream+cancel too.
Rejected (category-5, wrong-grounding) — 2/3 arms flagged create's bound-502 as
"should gate on error_code like list's 422+cursor_invalid". Verified against the SDK
parser (not in the arms' file set): the bound-502 body is
{"error_code":"bifrost_handshake_failed","detail":{"bifrost_error":...}}, and the
SDK's envelope parser PREFERS the nested detail (which lacks error_code), so
ApiError.error_code resolves to "unknown" — gating would REGRESS handshake detection
(the cli/web integration tests caught it). INV-002 also makes the handshake the sole
bound-502 cause. Kept the any-bound-502 mapping; documented WHY in code + contract.
Accepted-as-is: create_session -> Mapping annotation (intentional open-world
passthrough, already documented in the route-map note; category 3).
Suite 493 green; wt.py mypy + ruff clean. Patch.
This commit is contained in:
@@ -158,18 +158,23 @@ others, they get their own row here — the default is NOT a general "any 404
|
|||||||
| SDK `AgentNotAvailable` / `TurnLaunchUnavailable` / `SessionRetired` (stream-open) | ratatoskr `AgentNotAvailable` / `TurnLaunchUnavailable` / (retired → `SessionApiFailed`) — same names, passthrough |
|
| SDK `AgentNotAvailable` / `TurnLaunchUnavailable` / `SessionRetired` (stream-open) | ratatoskr `AgentNotAvailable` / `TurnLaunchUnavailable` / (retired → `SessionApiFailed`) — same names, passthrough |
|
||||||
| SDK `ConnectionDropped` (mid-stream) | `SseConnectionDropped` |
|
| SDK `ConnectionDropped` (mid-stream) | `SseConnectionDropped` |
|
||||||
| SDK `ResumeError` subclasses (in resilient stream) | resilient `stream_turn` absorbs; terminal → `SseConnectFailed` |
|
| SDK `ResumeError` subclasses (in resilient stream) | resilient `stream_turn` absorbs; terminal → `SseConnectFailed` |
|
||||||
| SDK `Cancel*` (cancel_turn) | folded into `CancelResult`; late-cancel race (B-CAN-3) returns `cancelled=False`, never raises |
|
| SDK `MalformedSseId` / `MalformedSseData` / `TurnIdFlip` (stream `ProtocolError`) | ratatoskr same-named types — same-name rewrap of the discriminated stream protocol errors |
|
||||||
|
| SDK `Cancel*` (cancel_turn) — the SDK RAISES the typed races | 404 `turn_not_found` → `CancelTurnNotFound`; 409 `turn_finished` → `CancelAlreadyCompleted`; other `CancelError` → `CancelFailed`. A 200 (incl. `cancelled=False`, the B-CAN-3 late-cancel no-op) returns a `CancelResult` — never raises. The caller surface stays exception-based (DEC-2; matches the pre-cutover CLI/web handlers). |
|
||||||
| `ApiError(404)` on `sessions.create` | `AgentNotFound` |
|
| `ApiError(404)` on `sessions.create` | `AgentNotFound` |
|
||||||
| `ApiError(404)` on `sessions.write_history` | `AuthoredHistoryUnavailable` (hide-existence) |
|
| `ApiError(404)` on `sessions.write_history` | `AuthoredHistoryUnavailable` (hide-existence) |
|
||||||
| `ApiError(422 cursor_invalid)` on `sessions.list` | `InvalidCursor` |
|
| `ApiError(422 cursor_invalid)` on `sessions.list` | `InvalidCursor` (dual-key: status 422 AND error_code; the flat cursor body surfaces the code) |
|
||||||
| `ApiError(502 bifrost_handshake_failed)` on bound `sessions.create` | `BifrostHandshakeFailed` |
|
| `ApiError(502)` on bound `sessions.create` | `BifrostHandshakeFailed` — NOT gated on error_code (unlike list's 422): INV-002, the synchronous handshake is the SOLE bound-502 cause; and the SDK's envelope parser prefers the nested `detail` (which carries `bifrost_error`, not `error_code`), so no distinguishing top-level `error_code` surfaces. The route+status IS the discriminator. |
|
||||||
| **`ApiError` (any other status/route) — the default** | `SessionApiFailed(status, error_code, body)` |
|
| **`ApiError` (any other status/route) — the default** | `SessionApiFailed(status, error_code, body)` |
|
||||||
|
|
||||||
The default row is load-bearing: any `ApiError` not matched above surfaces as the
|
The default row is load-bearing: any `ApiError` not matched above surfaces as the
|
||||||
generic `SessionApiFailed` carrying the raw `status`/`error_code`/`body` — the
|
generic `SessionApiFailed` carrying the raw `status`/`error_code`/`body` — the
|
||||||
adapter does NOT invent per-route semantics the contract doesn't list, and does NOT
|
adapter does NOT invent per-route semantics the contract doesn't list, and does NOT
|
||||||
leave an `ApiError` un-mapped. Each slice adds/confirms its route's rows here before
|
leave an `ApiError` un-mapped. **This default holds on EVERY route, including the
|
||||||
the old path is deleted.
|
stream and cancel** (each carries a defensive `except ApiError → SessionApiFailed`
|
||||||
|
after its discriminated branches — the SDK maps those routes to discriminated types
|
||||||
|
today, but the default guarantees INV-CUT-2 structurally, not by SDK-internal
|
||||||
|
coupling). Each slice adds/confirms its route's rows here before the old path is
|
||||||
|
deleted.
|
||||||
|
|
||||||
## Slice plan (incremental, DEC-4)
|
## Slice plan (incremental, DEC-4)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "ratatoskr"
|
name = "ratatoskr"
|
||||||
version = "0.21.8"
|
version = "0.21.9"
|
||||||
description = "Worldtree Conversation API debug console (web + headless CLI) — multi-pane observability"
|
description = "Worldtree Conversation API debug console (web + headless CLI) — multi-pane observability"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
@@ -197,6 +197,12 @@ async def create_session(
|
|||||||
except ApiError as exc:
|
except ApiError as exc:
|
||||||
if exc.status == 404:
|
if exc.status == 404:
|
||||||
raise AgentNotFound(agent_id=agent_id) from exc
|
raise AgentNotFound(agent_id=agent_id) from exc
|
||||||
|
# NOT gated on error_code (unlike list's 422+cursor_invalid): INV-002 — a 502
|
||||||
|
# on a BOUND create IS the synchronous Bifrost handshake failing, the sole
|
||||||
|
# bound-502 cause; and the SDK does not surface a distinguishing top-level
|
||||||
|
# error_code here (its envelope parser prefers the nested `detail`, which
|
||||||
|
# carries `bifrost_error`, not `error_code`). The nested bifrost_error is
|
||||||
|
# extracted for the exception; the route+status is the discriminator.
|
||||||
if bifrost is not None and exc.status == 502:
|
if bifrost is not None and exc.status == 502:
|
||||||
raise BifrostHandshakeFailed(
|
raise BifrostHandshakeFailed(
|
||||||
bifrost_error=_bifrost_error_from_body(exc.body),
|
bifrost_error=_bifrost_error_from_body(exc.body),
|
||||||
@@ -299,6 +305,10 @@ async def stream_turn(
|
|||||||
raise MalformedSseData(raw=exc.raw) from exc
|
raise MalformedSseData(raw=exc.raw) from exc
|
||||||
except wtsdk.TurnIdFlip as exc:
|
except wtsdk.TurnIdFlip as exc:
|
||||||
raise TurnIdFlip(established=exc.established, got=exc.got) from exc
|
raise TurnIdFlip(established=exc.established, got=exc.got) from exc
|
||||||
|
except ApiError as exc:
|
||||||
|
# INV-CUT-2 default: an undiscriminated ApiError surfacing from the stream →
|
||||||
|
# SessionApiFailed (the discriminated stream errors are handled above).
|
||||||
|
raise translate_error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
async def cancel_turn(
|
async def cancel_turn(
|
||||||
@@ -323,3 +333,6 @@ async def cancel_turn(
|
|||||||
raise CancelFailed(
|
raise CancelFailed(
|
||||||
status=0, body=(getattr(exc, "message", "") or str(exc)).encode()
|
status=0, body=(getattr(exc, "message", "") or str(exc)).encode()
|
||||||
) from exc
|
) from exc
|
||||||
|
except ApiError as exc:
|
||||||
|
# INV-CUT-2 default: an undiscriminated ApiError on this route → SessionApiFailed.
|
||||||
|
raise translate_error(exc) from exc
|
||||||
|
|||||||
@@ -299,6 +299,12 @@ class TestReadPassthroughs:
|
|||||||
await get_session_messages(_wt(fake), "s")
|
await get_session_messages(_wt(fake), "s")
|
||||||
assert ei.value.status == 401
|
assert ei.value.status == 401
|
||||||
|
|
||||||
|
async def test_tools_error_maps_to_session_api_failed(self) -> None:
|
||||||
|
fake = _FakeSessions(error=ApiError("auth_revoked", "no", status=401))
|
||||||
|
with pytest.raises(SessionApiFailed) as ei:
|
||||||
|
await get_session_tools(_wt(fake), "s")
|
||||||
|
assert ei.value.status == 401
|
||||||
|
|
||||||
|
|
||||||
async def _drain(aiter: Any) -> list[Any]:
|
async def _drain(aiter: Any) -> list[Any]:
|
||||||
out: list[Any] = []
|
out: list[Any] = []
|
||||||
@@ -376,6 +382,14 @@ class TestStreamTurn:
|
|||||||
await _drain(stream_turn(_wt(fake), "s", "hi"))
|
await _drain(stream_turn(_wt(fake), "s", "hi"))
|
||||||
assert (ei.value.established, ei.value.got) == (5, 7)
|
assert (ei.value.established, ei.value.got) == (5, 7)
|
||||||
|
|
||||||
|
async def test_undiscriminated_api_error_maps_to_session_api_failed(self) -> None:
|
||||||
|
# INV-CUT-2 default: an undiscriminated ApiError surfacing from the stream
|
||||||
|
# (not a discriminated stream error) → SessionApiFailed.
|
||||||
|
fake = _FakeSessions(stream_error=ApiError("weird", "boom", status=500))
|
||||||
|
with pytest.raises(SessionApiFailed) as ei:
|
||||||
|
await _drain(stream_turn(_wt(fake), "s", "hi"))
|
||||||
|
assert ei.value.status == 500
|
||||||
|
|
||||||
|
|
||||||
class TestCancelTurn:
|
class TestCancelTurn:
|
||||||
async def test_happy_returns_cancel_result(self) -> None:
|
async def test_happy_returns_cancel_result(self) -> None:
|
||||||
@@ -410,3 +424,11 @@ class TestCancelTurn:
|
|||||||
fake = _FakeSessions(error=wtsdk.CancelFailed(42, error_code="boom", message="failed"))
|
fake = _FakeSessions(error=wtsdk.CancelFailed(42, error_code="boom", message="failed"))
|
||||||
with pytest.raises(CancelFailed):
|
with pytest.raises(CancelFailed):
|
||||||
await cancel_turn(_wt(fake), "s", 42)
|
await cancel_turn(_wt(fake), "s", 42)
|
||||||
|
|
||||||
|
async def test_undiscriminated_api_error_maps_to_session_api_failed(self) -> None:
|
||||||
|
# INV-CUT-2 default: an undiscriminated ApiError on the cancel route (not a
|
||||||
|
# typed Cancel* race) → SessionApiFailed, never leaked as a bare ApiError.
|
||||||
|
fake = _FakeSessions(error=ApiError("weird", "boom", status=500))
|
||||||
|
with pytest.raises(SessionApiFailed) as ei:
|
||||||
|
await cancel_turn(_wt(fake), "s", 42)
|
||||||
|
assert ei.value.status == 500
|
||||||
|
|||||||
Reference in New Issue
Block a user