feat(fix): surface inbound decode failures (closes #432)#439
Conversation
decode_app_message returns None for two distinct cases — no registered model (admin/unimplemented) and a registered model whose payload failed validation — so a genuine ExecutionReport lost to one off-spec field was silently dropped. - errors.py: FixDecodeError (carries raw + msg_type; chains the underlying validation error) for the registered-but-malformed case. - dispatch.py: decode_app_message_strict raises FixDecodeError on a malformed known message (None only for unregistered types); decode_app_message now delegates to it and swallows (its lenient None contract is unchanged). - FixSession: optional on_decode_error hook (the issue's recommended option 3). When set, the session decodes each inbound app message to detect a malformed known one and routes it (raw + FixDecodeError) so nothing is silently lost; the raw is still delivered to on_message. Zero behaviour change when unset; threaded through all client session factories. 7 new tests: strict vs lenient decode (valid / malformed-raises / unregistered- None / swallowed), and FixSession routing — malformed ExecutionReport surfaces via on_decode_error (raw still delivered), a valid report does not trigger it, and without the hook the raw is still delivered and the session survives. ruff + mypy --strict clean; 259 FIX tests pass. Closes #432. Part of #402. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Code ReviewOverall: Well-scoped, clean implementation of option 3 from #432. The three-layer design (strict decoder → lenient wrapper → session hook) is the right shape for this problem. A few items worth discussing before merge. Design concern: double-decode in the happy pathThe most impactful issue. In if self._on_decode_error is not None:
try:
decode_app_message_strict(raw) # result thrown away on happy path
except FixDecodeError as exc:
...
if self._on_message is not None:
await self._on_message(raw) # consumer re-decodes from rawThis means a Options:
Option 1 is fine for v1 of the feature, but the docstring on Minor issues
# errors.py
def __init__(
self, message: str, *, raw: RawMessage | None = None, msg_type: str | None = None
) -> None:
The test pushes only await acceptor.push("8", [(int(Tag.AVG_PX), "0.66")], seq=2)Missing test: The production code at Comment style The comment block at What's working well
SummaryThe double-decode cost is the only issue I'd want resolved before merge — either accept it with explicit documentation, or find a way to reuse the decoded result. The other items are minor polish. The feature itself is the right fix for #432. |
…ping - Document the on_decode_error decode cost prominently in the FixSession class docstring (it runs a full Pydantic validation on every inbound app message to detect a malformed one; pairs with on_message as a second pass on a busy session) and accept it for v1; trim the verbose __init__ comment to a pointer. - FixDecodeError.raw / .msg_type are now required (non-optional) — they are always present when raised, so consumers no longer null-check. dispatch binds the registered MsgType to a local so it is a real str (mypy-clean). - tests: valid-message no-trigger test now uses a typed AVG_PX value (exercises decode, not just field presence); add a test that a raising on_decode_error callback is isolated (raw still delivered, session stays active). Double-decode design: kept option 3 (on_decode_error hook) per the issue's recommendation; the happy-path re-decode is opt-in (zero cost when unset) and now explicitly documented. A decode-once typed-delivery path is a future enhancement (the issue noted this lands best with typed inbound delivery). ruff + mypy --strict clean; 260 FIX tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Code Review: feat(fix): surface inbound decode failures (closes #432)Overall: This is a clean, well-scoped change that solves a real observability gap. The design is solid, conventions are followed, and the backward-compatible hook pattern is the right approach. What the PR doesAdds an Issues / observations1. Double-decode when In 2. Test count mismatch The PR description says "7 new" tests, but 3. No test for When 4. The docstring is unusually long for an exception class in this codebase and repeats information already in the What is well done
VerdictApprove with minor notes. The double-decode and missing edge-case test are worth a follow-up issue but not PR-blocking. The core design is correct and the implementation is clean. |
Summary
Make a malformed inbound application message observable instead of silently dropped.
decode_app_message(raw)returnsNonefor two distinct cases — (1) no registered model (admin / unimplemented flow) and (2) a registered model whose payload failed schema validation (e.g. aDollarDecimalin an off-spec format). Wired into a production consumer, case (2) means a genuineExecutionReportcould be lost to a single bad field with only alogger.warning. This implements the issue's recommended option 3 (anon_decode_errorhook onFixSession), plus a strict decoder for direct callers.Changes
errors.py—FixDecodeError(carriesraw+msg_type, chains the underlying validation error via__cause__) for the registered-but-malformed case.dispatch.py—decode_app_message_strict(raw)raisesFixDecodeErroron a malformed known message (returnsNoneonly for an unregistered type).decode_app_messagenow delegates to it and swallows — its lenientNonecontract is unchanged.FixSession— optionalon_decode_error: (RawMessage, FixDecodeError) -> Awaitable[None]. When set, the session decodes each inbound app message to detect a malformed known one and routes it there (the raw is still delivered toon_message, so nothing is lost). Zero behaviour change when unset. Threaded through every client session factory (order_entry/drop_copy/market_data/post_trade/rfq/session).Tests (
tests/fix/test_decode_routing.py, 7 new)decode_app_message_strictvalid / malformed-raises / unregistered-None;decode_app_messagestill swallows; andFixSessionrouting — a malformedExecutionReportsurfaces viaon_decode_error(raw still delivered), a valid report doesn't trigger it, and without the hook the raw is still delivered and the session staysactive.Verification
uv run ruff check .— cleanuv run mypy kalshi/(strict) — clean (158 files)uv run pytest tests/fix/— 259 passedCloses #432. Part of #402.
🤖 Generated with Claude Code