From 1fefe1c5730bebe34d559cb4c4aeb6d660d334d4 Mon Sep 17 00:00:00 2001 From: Stephen Young Date: Wed, 6 May 2026 22:41:10 -0400 Subject: [PATCH 1/2] Add context manager and close() to ClientBase Allows using CustomerIO and APIClient as context managers to properly close the underlying requests Session and avoid ResourceWarning for unclosed SSL sockets. Closes #87 --- customerio/client_base.py | 11 +++++++++++ tests/test_client_base.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/customerio/client_base.py b/customerio/client_base.py index 3370665..5583a61 100644 --- a/customerio/client_base.py +++ b/customerio/client_base.py @@ -24,6 +24,17 @@ def __init__(self, retries=3, timeout=10, backoff_factor=0.02, use_connection_po self.use_connection_pooling = use_connection_pooling self._current_session = None + def __enter__(self): + return self + + def __exit__(self, *args): + self.close() + + def close(self): + if self._current_session is not None: + self._current_session.close() + self._current_session = None + @property def http(self): if self._current_session is None: diff --git a/tests/test_client_base.py b/tests/test_client_base.py index efaf624..cbd6b11 100644 --- a/tests/test_client_base.py +++ b/tests/test_client_base.py @@ -134,6 +134,35 @@ def build_session(resp=response): result = client.send_request("POST", "https://example.com", {}) self.assertEqual(result.status_code, status) + def test_context_manager_closes_session(self): + client = ClientBase(use_connection_pooling=True) + session = FakeSession() + client._build_session = lambda: session + + with client: + client.send_request("POST", "https://example.com", {}) + self.assertFalse(session.closed) + + self.assertTrue(session.closed) + self.assertIsNone(client._current_session) + + def test_close_without_session(self): + client = ClientBase() + client.close() + self.assertIsNone(client._current_session) + + def test_close_resets_session(self): + client = ClientBase(use_connection_pooling=True) + session = FakeSession() + client._build_session = lambda: session + + client.send_request("POST", "https://example.com", {}) + self.assertIsNotNone(client._current_session) + + client.close() + self.assertTrue(session.closed) + self.assertIsNone(client._current_session) + if __name__ == "__main__": unittest.main() From 979dbea534f6b301c9d84654b266074635e8ae79 Mon Sep 17 00:00:00 2001 From: Stephen Young Date: Thu, 7 May 2026 23:10:03 -0400 Subject: [PATCH 2/2] Reset session ref in finally block to prevent stale state If session.close() raises, _current_session was never reset to None, leaving the client unable to create a new session on subsequent calls. --- customerio/client_base.py | 6 ++++-- tests/test_client_base.py | 11 +++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/customerio/client_base.py b/customerio/client_base.py index 5583a61..0087faf 100644 --- a/customerio/client_base.py +++ b/customerio/client_base.py @@ -32,8 +32,10 @@ def __exit__(self, *args): def close(self): if self._current_session is not None: - self._current_session.close() - self._current_session = None + try: + self._current_session.close() + finally: + self._current_session = None @property def http(self): diff --git a/tests/test_client_base.py b/tests/test_client_base.py index cbd6b11..7031519 100644 --- a/tests/test_client_base.py +++ b/tests/test_client_base.py @@ -163,6 +163,17 @@ def test_close_resets_session(self): self.assertTrue(session.closed) self.assertIsNone(client._current_session) + def test_close_resets_session_even_on_error(self): + client = ClientBase(use_connection_pooling=True) + session = FakeSession() + session.close = lambda: (_ for _ in ()).throw(RuntimeError("close failed")) + client._current_session = session + + with self.assertRaises(RuntimeError): + client.close() + + self.assertIsNone(client._current_session) + if __name__ == "__main__": unittest.main()