From cdb3016e9cb959eb1cba36cf15e47eb2f172b530 Mon Sep 17 00:00:00 2001 From: Dylan Pulver Date: Tue, 1 Sep 2026 16:54:16 +0300 Subject: [PATCH] fix: raise the exception, not a 1-tuple, on stream transport errors The ReadTimeout and ConnectionError handlers in StreamListener.handle_stream ended the constructor call with a trailing comma, so `exception` was a 1-tuple. `raise exception from err` therefore raised TypeError: exceptions must derive from BaseException and on_abort was handed the tuple instead of the exception object its docstring documents. This also broke auto-reconnect: Mastodon.__stream catches (AttributeError, MastodonMalformedEventError, MastodonNetworkError) to decide whether to reconnect, and TypeError is not in that tuple, so a read timeout or connection reset terminated the streaming thread. Reported in #368 and closed as presumed fixed by dropping six; dropping six turned six.raise_from(tuple, err) into `raise tuple from err`, which fails identically. The ChunkedEncodingError branch never had the comma. Adds a test covering all three transport error branches. --- mastodon/streaming.py | 4 ++-- tests/test_streaming.py | 37 ++++++++++++++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/mastodon/streaming.py b/mastodon/streaming.py index 0bb79fea..3f622b7f 100644 --- a/mastodon/streaming.py +++ b/mastodon/streaming.py @@ -157,12 +157,12 @@ def handle_stream(self, response): raise exception from err except ReadTimeout as err: exception = MastodonReadTimeout( - "Timed out while reading from server."), + "Timed out while reading from server.") self.on_abort(exception) raise exception from err except ConnectionError as err: exception = MastodonNetworkError( - "Requests reports connection error."), + "Requests reports connection error.") self.on_abort(exception) raise exception from err diff --git a/tests/test_streaming.py b/tests/test_streaming.py index cc1eb8f4..83eb1492 100644 --- a/tests/test_streaming.py +++ b/tests/test_streaming.py @@ -1,8 +1,9 @@ import pytest import itertools from mastodon.streaming import StreamListener, CallbackStreamListener -from mastodon.Mastodon import MastodonMalformedEventError +from mastodon.Mastodon import MastodonMalformedEventError, MastodonNetworkError, MastodonReadTimeout from mastodon import Mastodon +from requests.exceptions import ChunkedEncodingError, ConnectionError, ReadTimeout import threading import time @@ -90,6 +91,10 @@ def __init__(self): self.heartbeats = 0 self.bla_called = False self.do_something_called = False + self.aborts = [] + + def on_abort(self, err): + self.aborts.append(err) def on_update(self, status): self.updates.append(status) @@ -345,6 +350,36 @@ def test_multiline_payload(): ]) assert listener.updates == [{"foo": "bar"}] + +class RaisingResponse(): + """A response object whose iter_content immediately raises `exception`.""" + def __init__(self, exception): + self.exception = exception + + def iter_content(self, chunk_size): + raise self.exception + yield # pragma: no cover - makes this a generator function + + +@pytest.mark.parametrize("raised,expected", [ + (ChunkedEncodingError("nope"), MastodonNetworkError), + (ReadTimeout("nope"), MastodonReadTimeout), + (ConnectionError("nope"), MastodonNetworkError), +]) +def test_handle_stream_transport_errors(raised, expected): + """ + Transport level errors have to surface as the matching Mastodon.py error, and the + same object has to be handed to on_abort. Everything that reaches the caller of + handle_stream must also be a MastodonNetworkError, because that is what the + auto-reconnect handler in Mastodon.__stream catches to decide to reconnect. + """ + listener = Listener() + with pytest.raises(expected) as exc_info: + listener.handle_stream(RaisingResponse(raised)) + assert isinstance(exc_info.value, MastodonNetworkError) + assert exc_info.value.__cause__ is raised + assert listener.aborts == [exc_info.value] + @pytest.mark.vcr(match_on=['path']) def test_stream_user_direct(api, api2, api3, vcr): patch_streaming()