From 69a85cbc7622d6f91e4265fa06a1489f254dc4d5 Mon Sep 17 00:00:00 2001 From: igor-ctrl Date: Mon, 4 May 2026 15:16:57 -0500 Subject: [PATCH] fix(tests): catch ConnectionError in WorkOS callback drain calls (Python 3.12+) The callback-state tests open a localhost listener in a daemon thread, hit it via urllib.urlopen(), then call urlopen() once more to drain the listener so the daemon thread exits cleanly. The drain call only caught urllib.error.URLError. Python 3.12+ propagates raw ConnectionResetError unwrapped when the server thread closes the socket mid-read (in http.client._read_status). ConnectionResetError is a sibling of URLError under OSError, not a subclass, so it leaked through and crashed the test on the matrix's 3.12 / 3.13 jobs while 3.11 was forgiving. Surfaced as a flake on the feat/agent-context merge to main; same code passed on the PR run minutes earlier. Widen the except to (URLError, ConnectionError) at every drain site in the file. ConnectionError is the parent of all four ConnectionXxxError variants and is more semantically pointed than a broad OSError catch. The tests' actual assertions live on the threaded login result, not the urlopen response, so swallowing network errors on these specific drain calls is safe. Verified locally on Python 3.14 (4/4 pass) and via the rerun matrix once this lands. --- tests/test_auth/test_workos_callback_state.py | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/tests/test_auth/test_workos_callback_state.py b/tests/test_auth/test_workos_callback_state.py index 09971b0..88c0132 100644 --- a/tests/test_auth/test_workos_callback_state.py +++ b/tests/test_auth/test_workos_callback_state.py @@ -154,7 +154,12 @@ def test_authorization_url_includes_state(fake_workos, isolated_identity_cache): f"?code=ok&state={state}", timeout=2, ) - except urllib.error.URLError: + except (urllib.error.URLError, ConnectionError): + # urllib.error.URLError covers DNS / connection-refused; ConnectionError + # (incl. ConnectionResetError) leaks through unwrapped on Python 3.12+ + # when the server thread closes the socket while we're still reading + # the response. Either is fine for these drain calls — the test's real + # assertions live elsewhere; this just lets the daemon thread exit. pass thread.join(timeout=5) @@ -219,7 +224,12 @@ def test_callback_with_wrong_path_is_404(fake_workos, isolated_identity_cache): urllib.request.urlopen(bad_url, timeout=2) except urllib.error.HTTPError as http_err: assert http_err.code == 404 - except urllib.error.URLError: + except (urllib.error.URLError, ConnectionError): + # urllib.error.URLError covers DNS / connection-refused; ConnectionError + # (incl. ConnectionResetError) leaks through unwrapped on Python 3.12+ + # when the server thread closes the socket while we're still reading + # the response. Either is fine for these drain calls — the test's real + # assertions live elsewhere; this just lets the daemon thread exit. pass # The 404 path must not consume the listener — the legitimate callback @@ -230,7 +240,12 @@ def test_callback_with_wrong_path_is_404(fake_workos, isolated_identity_cache): ) try: urllib.request.urlopen(good_url, timeout=2) - except urllib.error.URLError: + except (urllib.error.URLError, ConnectionError): + # urllib.error.URLError covers DNS / connection-refused; ConnectionError + # (incl. ConnectionResetError) leaks through unwrapped on Python 3.12+ + # when the server thread closes the socket while we're still reading + # the response. Either is fine for these drain calls — the test's real + # assertions live elsewhere; this just lets the daemon thread exit. pass thread.join(timeout=5) @@ -252,7 +267,12 @@ def test_matching_state_is_accepted(fake_workos, isolated_identity_cache): ) try: urllib.request.urlopen(url, timeout=2) - except urllib.error.URLError: + except (urllib.error.URLError, ConnectionError): + # urllib.error.URLError covers DNS / connection-refused; ConnectionError + # (incl. ConnectionResetError) leaks through unwrapped on Python 3.12+ + # when the server thread closes the socket while we're still reading + # the response. Either is fine for these drain calls — the test's real + # assertions live elsewhere; this just lets the daemon thread exit. pass thread.join(timeout=5)