Skip to content

Commit 8eaefdd

Browse files
Validate message type in decode_continuation() to surface server errors
decode_continuation() now checks header.msg_type before parsing the body as row data. If the server sends a FailureResponse mid-stream (e.g. leadership lost), the error code and message are raised as a ProtocolError instead of producing a confusing DecodeError from the row-tuple parser. Unexpected message types also raise ProtocolError. Fixes #013 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 933315f commit 8eaefdd

2 files changed

Lines changed: 41 additions & 0 deletions

File tree

src/dqlitewire/codec.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,17 @@ def decode_continuation(
204204

205205
header = Header.decode(data[:HEADER_SIZE])
206206
body = data[HEADER_SIZE : HEADER_SIZE + header.size_words * 8]
207+
208+
if header.msg_type == ResponseType.FAILURE:
209+
failure = FailureResponse.decode_body(body, schema=header.schema)
210+
raise ProtocolError(
211+
f"Server error during ROWS continuation: [{failure.code}] {failure.message}"
212+
)
213+
if header.msg_type != ResponseType.ROWS:
214+
raise ProtocolError(
215+
f"Expected ROWS continuation (type {ResponseType.ROWS}), got type {header.msg_type}"
216+
)
217+
207218
return RowsResponse.decode_rows_continuation(
208219
body, column_names, column_count, max_rows=max_rows
209220
)

tests/test_codec.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -804,6 +804,36 @@ def test_decode_continuation_returns_none_when_no_data(self) -> None:
804804
result = decoder.decode_continuation(column_names=["x"], column_count=1)
805805
assert result is None
806806

807+
def test_decode_continuation_raises_on_failure_response(self) -> None:
808+
"""decode_continuation should surface server error, not a confusing DecodeError."""
809+
from dqlitewire.exceptions import ProtocolError
810+
from dqlitewire.messages.responses import FailureResponse
811+
812+
failure = FailureResponse(code=266, message="disk I/O error")
813+
failure_bytes = failure.encode()
814+
815+
decoder = MessageDecoder(is_request=False)
816+
decoder.feed(failure_bytes)
817+
818+
with pytest.raises(ProtocolError, match="disk I/O error") as exc_info:
819+
decoder.decode_continuation(column_names=["id"], column_count=1)
820+
# Must be a ProtocolError, NOT a DecodeError (which would mean the
821+
# failure body was misinterpreted as row data).
822+
assert type(exc_info.value) is ProtocolError
823+
824+
def test_decode_continuation_raises_on_unexpected_type(self) -> None:
825+
"""decode_continuation should raise ProtocolError for non-ROWS, non-FAILURE type."""
826+
from dqlitewire.exceptions import ProtocolError
827+
828+
result = ResultResponse(last_insert_id=0, rows_affected=0)
829+
result_bytes = result.encode()
830+
831+
decoder = MessageDecoder(is_request=False)
832+
decoder.feed(result_bytes)
833+
834+
with pytest.raises(ProtocolError, match="Expected ROWS continuation"):
835+
decoder.decode_continuation(column_names=["id"], column_count=1)
836+
807837

808838
class TestDecoderSkipMessage:
809839
"""Test skip_message() and is_skipping on MessageDecoder."""

0 commit comments

Comments
 (0)