From 94952e6471f92c2100284d85ba259cafb408bab3 Mon Sep 17 00:00:00 2001 From: Daniel McCoy Stephenson Date: Wed, 30 Sep 2026 03:00:46 -0600 Subject: [PATCH] Keep the sender thread alive if _send ever raises _drain now wraps each _send call in a try that logs at DEBUG and moves on, so the thread's survival no longer depends on every future edit to _send keeping all raising code inside its own try. No behavior change today. Closes #5 Co-Authored-By: Claude Opus 5.5 --- tests/test_trace_client.py | 24 ++++++++++++++++++++++++ trace_client/trace_client.py | 5 ++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/tests/test_trace_client.py b/tests/test_trace_client.py index 7d5d59f..e1150ea 100644 --- a/tests/test_trace_client.py +++ b/tests/test_trace_client.py @@ -247,6 +247,30 @@ def test_a_base_url_without_a_scheme_is_logged_not_a_dead_thread(self): % [r.getMessage() for r in self.log]) self.assertTrue(all(r.levelno == logging.DEBUG for r in self.log)) + def test_the_sender_thread_survives_an_unexpected_error_from_send(self): + crashes = [] + real_send = TraceClient._send + calls = [] + + def send_that_fails_once(client, body): + calls.append(body) + if len(calls) == 1: + raise RuntimeError("boom") + real_send(client, body) + + with mock.patch.object(threading, "excepthook", crashes.append), \ + mock.patch.object(TraceClient, "_send", send_that_fails_once): + client = TraceClient(self.base_url, "MyGame", "1.2.3", key="k") + client.report("first") + client.report("second") + self.assertTrue(self.capture.arrived.wait(5), "a later report is still delivered") + client.close() + self.assertEqual([], crashes, "the sender thread must not die with a traceback on stderr") + self.assertEqual(["second"], [json.loads(r["body"])["name"] for r in self.capture.requests]) + self.assertTrue(any("sender failed" in r.getMessage() and "boom" in r.getMessage() for r in self.log), + [r.getMessage() for r in self.log]) + self.assertTrue(all(r.levelno == logging.DEBUG for r in self.log)) + def test_constructor_rejects_a_missing_base_url_or_application(self): for base_url, application in ((None, "MyGame"), (" ", "MyGame"), ("http://x", None), ("http://x", "")): with self.assertRaises(ValueError): diff --git a/trace_client/trace_client.py b/trace_client/trace_client.py index 0954f67..2c3797f 100644 --- a/trace_client/trace_client.py +++ b/trace_client/trace_client.py @@ -198,7 +198,10 @@ def _drain(self) -> None: body = q.get() if body is None: return - self._send(body) + try: + self._send(body) + except Exception as failure: # noqa: BLE001 - a dead sender would leave the queue filling forever + _LOG.debug("[trace] sender failed on %s: %s", body, failure) def _send(self, body: bytes) -> None: try: