Skip to content

Commit 3100ac1

Browse files
fix: make ReadBuffer.has_message() total (non-raising)
has_message() is a predicate and must not raise — the documented `while decoder.has_message(): decode()` pattern required callers to wrap the check itself in try/except to handle oversized headers. Move the max_message_size check into read_message() so the error surfaces at consume time. has_message() now returns True when a header claims an oversized body, signalling "there is something to consume; the consume call will tell you whether it's valid." skip_message() is unchanged — it already handles the oversized path as the documented recovery mechanism. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5480e73 commit 3100ac1

3 files changed

Lines changed: 64 additions & 14 deletions

File tree

src/dqlitewire/buffer.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,14 @@ def feed(self, data: bytes) -> None:
6767
self._data.extend(data)
6868

6969
def has_message(self) -> bool:
70-
"""Check if a complete message is available."""
70+
"""Check if a complete message is available.
71+
72+
This predicate is total: it never raises. An oversized header
73+
(claiming a body larger than ``max_message_size``) is reported as
74+
``True`` so that the caller's ``while decoder.has_message(): ...``
75+
loop proceeds to the consume call, where the error actually surfaces.
76+
The raise lives in ``read_message()`` / ``skip_message()``, not here.
77+
"""
7178
available = len(self._data) - self._pos
7279

7380
if available < HEADER_SIZE:
@@ -78,9 +85,8 @@ def has_message(self) -> bool:
7885
total_size = HEADER_SIZE + (size_words * WORD_SIZE)
7986

8087
if total_size > self._max_message_size:
81-
raise DecodeError(
82-
f"Message size {total_size} bytes exceeds maximum {self._max_message_size}"
83-
)
88+
# Report "something to consume" — the consume call will raise.
89+
return True
8490

8591
return available >= total_size
8692

@@ -104,13 +110,24 @@ def read_message(self) -> bytes | None:
104110
"""Read a complete message from the buffer.
105111
106112
Returns the message data (including header) or None if not enough data.
113+
Raises ``DecodeError`` if the next buffered header claims a message
114+
larger than ``max_message_size``. Use ``skip_message()`` to recover.
107115
"""
108-
if not self.has_message():
116+
available = len(self._data) - self._pos
117+
if available < HEADER_SIZE:
109118
return None
110119

111120
size_words = int.from_bytes(self._data[self._pos : self._pos + 4], "little")
112121
total_size = HEADER_SIZE + (size_words * WORD_SIZE)
113122

123+
if total_size > self._max_message_size:
124+
raise DecodeError(
125+
f"Message size {total_size} bytes exceeds maximum {self._max_message_size}"
126+
)
127+
128+
if available < total_size:
129+
return None
130+
114131
message = bytes(self._data[self._pos : self._pos + total_size])
115132
self._pos += total_size
116133
self._maybe_compact()

tests/test_buffer.py

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -142,8 +142,34 @@ def test_clear(self) -> None:
142142
buf.clear()
143143
assert buf.available() == 0
144144

145+
def test_has_message_is_total_on_oversized(self) -> None:
146+
"""has_message() must not raise — oversized headers surface at consume time.
147+
148+
Callers using the documented `while decoder.has_message(): decode()` pattern
149+
must not have to wrap the check itself in try/except. The raise belongs at
150+
read_message() / skip_message() / decode(), not at the predicate.
151+
"""
152+
import struct
153+
154+
import pytest
155+
156+
from dqlitewire.exceptions import DecodeError
157+
158+
buf = ReadBuffer(max_message_size=1024)
159+
# Header claiming a huge body: size_words=1000 (8000 bytes > 1024 limit)
160+
header = struct.pack("<IBBH", 1000, 0, 0, 0)
161+
buf.feed(header)
162+
163+
# The predicate is total: it returns True to signal "something is there
164+
# to consume" and never raises.
165+
assert buf.has_message() is True
166+
167+
# The raise happens at consume time.
168+
with pytest.raises(DecodeError, match="exceeds maximum"):
169+
buf.read_message()
170+
145171
def test_rejects_oversized_message(self) -> None:
146-
"""Messages exceeding max size should raise DecodeError."""
172+
"""Oversized messages surface at read_message(), not has_message()."""
147173
import struct
148174

149175
import pytest
@@ -154,8 +180,10 @@ def test_rejects_oversized_message(self) -> None:
154180
# Header claiming a huge body: size_words=1000 (8000 bytes > 1024 limit)
155181
header = struct.pack("<IBBH", 1000, 0, 0, 0)
156182
buf.feed(header)
183+
# has_message() is non-raising; consume-side surfaces the error.
184+
assert buf.has_message() is True
157185
with pytest.raises(DecodeError, match="exceeds maximum"):
158-
buf.has_message()
186+
buf.read_message()
159187

160188
def test_skip_message_recovers_from_oversized(self) -> None:
161189
"""After skipping an oversized message, stream should not be corrupted.
@@ -179,9 +207,10 @@ def test_skip_message_recovers_from_oversized(self) -> None:
179207
# Feed header + first chunk
180208
buf.feed(oversized_header + b"\xab" * 500)
181209

182-
# has_message should raise for oversized
210+
# has_message() is total; the raise happens on the consume call.
211+
assert buf.has_message() is True
183212
with pytest.raises(DecodeError, match="exceeds maximum"):
184-
buf.has_message()
213+
buf.read_message()
185214

186215
# skip_message should return False — partial skip
187216
assert buf.skip_message() is False
@@ -218,8 +247,9 @@ def test_skip_oversized_across_multiple_feeds(self) -> None:
218247

219248
# Feed just the header
220249
buf.feed(oversized_header)
250+
assert buf.has_message() is True
221251
with pytest.raises(DecodeError, match="exceeds maximum"):
222-
buf.has_message()
252+
buf.read_message()
223253
assert buf.skip_message() is False
224254

225255
# Feed capped body in chunks (max_message_size=64, header was 8 bytes,
@@ -251,8 +281,9 @@ def test_is_skipping_property(self) -> None:
251281
# Feed an oversized header (200 words = 1600 bytes > 64 limit)
252282
header = struct.pack("<IBBH", 200, 0, 0, 0)
253283
buf.feed(header)
284+
assert buf.has_message() is True
254285
with pytest.raises(DecodeError):
255-
buf.has_message()
286+
buf.read_message()
256287

257288
# Partial skip — is_skipping should be True
258289
assert buf.skip_message() is False
@@ -274,8 +305,9 @@ def test_clear_resets_skip_state(self) -> None:
274305
buf = ReadBuffer(max_message_size=64)
275306
header = struct.pack("<IBBH", 200, 0, 0, 0)
276307
buf.feed(header)
308+
assert buf.has_message() is True
277309
with pytest.raises(DecodeError):
278-
buf.has_message()
310+
buf.read_message()
279311
buf.skip_message()
280312
assert buf.is_skipping is True
281313

tests/test_codec.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -862,9 +862,10 @@ def test_skip_oversized_and_recover(self) -> None:
862862
# Feed just the oversized header
863863
decoder.feed(oversized_header)
864864

865-
# has_message() should raise for oversized
865+
# has_message() is total; the raise surfaces from decode() / read_message().
866+
assert decoder.has_message() is True
866867
with pytest.raises(DecodeError, match="exceeds maximum"):
867-
decoder.has_message()
868+
decoder.decode()
868869

869870
# skip_message() should handle the oversized message
870871
result = decoder.skip_message()

0 commit comments

Comments
 (0)