From db1b530d7f41ce58744c72692ae2173db6a8cff7 Mon Sep 17 00:00:00 2001 From: shawntru Date: Sun, 5 Apr 2026 21:24:23 -0400 Subject: [PATCH 1/4] do_api_query: Reset session on ConnectionError to fix stale connections. When a network middlebox silently drops an idle TCP connection, the next request raises a ConnectionError. Previously the retry logic would reuse the same dead session, failing up to 10 times. Now we close and null out the session on ConnectionError so ensure_session() creates a fresh one on the next retry. Fixes #761. --- zulip/zulip/__init__.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/zulip/zulip/__init__.py b/zulip/zulip/__init__.py index 82190a51d..03a5fcb2a 100644 --- a/zulip/zulip/__init__.py +++ b/zulip/zulip/__init__.py @@ -600,9 +600,6 @@ def do_api_query( req_files = [(f.name, f) for f in files] - self.ensure_session() - assert self.session is not None - query_state: Dict[str, Any] = { "had_error_retry": False, "request": request, @@ -637,6 +634,9 @@ def end_error_retry(succeeded: bool) -> None: print("Failed!") while True: + self.ensure_session() + assert self.session is not None + try: kwarg = "params" if method == "GET" else "data" @@ -686,6 +686,9 @@ def end_error_retry(succeeded: bool) -> None: raise UnrecoverableNetworkError( "cannot connect to server " + self.base_url ) from e + if self.session is not None: + self.session.close() + self.session = None if error_retry(""): continue From 4e02204f77293559e6d529d6e36ddb5ecc8cc9a5 Mon Sep 17 00:00:00 2001 From: shawntru Date: Sun, 5 Apr 2026 21:24:53 -0400 Subject: [PATCH 2/4] tests: Add test for stale connection session reset in do_api_query. Fixes #761. --- zulip/tests/test_do_api_query.py | 71 ++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 zulip/tests/test_do_api_query.py diff --git a/zulip/tests/test_do_api_query.py b/zulip/tests/test_do_api_query.py new file mode 100644 index 000000000..3ad4196fa --- /dev/null +++ b/zulip/tests/test_do_api_query.py @@ -0,0 +1,71 @@ +import unittest +from unittest.mock import MagicMock, patch, call +import requests +import zulip + + +def make_client() -> zulip.Client: + with patch.object(zulip.Client, "call_endpoint") as mock_call: + mock_call.return_value = { + "result": "success", + "zulip_version": "9.0.0", + "zulip_feature_level": 300, + "msg": "", + } + client = zulip.Client( + email="test@example.com", + api_key="deadbeef", + site="https://testserver", + ) + return client + + +class TestStaleConnectionRetry(unittest.TestCase): + def test_stale_connection_resets_session(self) -> None: + client = make_client() + + # Simulate a session that has already been used (has_connected = True) + stale_session = MagicMock() + client.session = stale_session + client.has_connected = True + + # First request raises ConnectionError (stale socket), + # second request succeeds + success_response = MagicMock() + success_response.status_code = 200 + success_response.json.return_value = {"result": "success", "msg": ""} + + stale_session.request.side_effect = requests.exceptions.ConnectionError("stale") + + fresh_session = MagicMock() + fresh_session.request.return_value = success_response + + original_ensure_session = zulip.Client.ensure_session + + call_count = 0 + + def mock_ensure_session(self: zulip.Client) -> None: + nonlocal call_count + call_count += 1 + if call_count == 1: + # First call: leave the stale session in place + self.session = stale_session + else: + # Subsequent calls: provide a fresh session + self.session = fresh_session + + with patch.object(zulip.Client, "ensure_session", mock_ensure_session): + result = client.do_api_query({}, "/api/v1/messages", method="POST") + + # The stale session should have been closed + stale_session.close.assert_called_once() + + # The result should come from the fresh session + self.assertEqual(result, {"result": "success", "msg": ""}) + + # ensure_session should have been called twice (once per loop iteration) + self.assertEqual(call_count, 2) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file From 44aeafdcc29021eb763b75ca746f4c2bb5df98bd Mon Sep 17 00:00:00 2001 From: shawntru Date: Sun, 5 Apr 2026 21:39:39 -0400 Subject: [PATCH 3/4] tests: Fix linting issues in test_do_api_query.py --- zulip/tests/test_do_api_query.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/zulip/tests/test_do_api_query.py b/zulip/tests/test_do_api_query.py index 3ad4196fa..34292f0f6 100644 --- a/zulip/tests/test_do_api_query.py +++ b/zulip/tests/test_do_api_query.py @@ -1,5 +1,6 @@ import unittest -from unittest.mock import MagicMock, patch, call +from unittest.mock import MagicMock, patch + import requests import zulip @@ -40,8 +41,6 @@ def test_stale_connection_resets_session(self) -> None: fresh_session = MagicMock() fresh_session.request.return_value = success_response - original_ensure_session = zulip.Client.ensure_session - call_count = 0 def mock_ensure_session(self: zulip.Client) -> None: From dd7b09fb21b0a824a57c7762c18ea32c752c8789 Mon Sep 17 00:00:00 2001 From: shawntru Date: Sun, 5 Apr 2026 21:46:12 -0400 Subject: [PATCH 4/4] tests: Fix linting issues in test_do_api_query.py --- zulip/tests/test_do_api_query.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/zulip/tests/test_do_api_query.py b/zulip/tests/test_do_api_query.py index 34292f0f6..2b4130047 100644 --- a/zulip/tests/test_do_api_query.py +++ b/zulip/tests/test_do_api_query.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock, patch import requests + import zulip @@ -67,4 +68,4 @@ def mock_ensure_session(self: zulip.Client) -> None: if __name__ == "__main__": - unittest.main() \ No newline at end of file + unittest.main()