Skip to content

Commit 61593d8

Browse files
committed
fix(http_client): make verbose mode thread-safe with threading.local
Use thread-local storage for _last_response so each thread sees its own response when sharing a DescopeClient instance. Prevents race condition where concurrent requests would overwrite each other's cf-ray headers.
1 parent 05643a1 commit 61593d8

File tree

2 files changed

+142
-6
lines changed

2 files changed

+142
-6
lines changed

descope/http_client.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import os
44
import platform
5+
import threading
56
from http import HTTPStatus
67
from typing import cast
78

@@ -171,7 +172,7 @@ def __init__(
171172
self.secure = secure
172173
self.management_key = management_key
173174
self.verbose = verbose
174-
self._last_response: DescopeResponse | None = None
175+
self._thread_local = threading.local()
175176

176177
# ------------- public API -------------
177178
def get(
@@ -191,7 +192,7 @@ def get(
191192
timeout=self.timeout_seconds,
192193
)
193194
if self.verbose:
194-
self._last_response = DescopeResponse(response)
195+
self._thread_local.last_response = DescopeResponse(response)
195196
self._raise_from_response(response)
196197
return response
197198

@@ -214,7 +215,7 @@ def post(
214215
timeout=self.timeout_seconds,
215216
)
216217
if self.verbose:
217-
self._last_response = DescopeResponse(response)
218+
self._thread_local.last_response = DescopeResponse(response)
218219
self._raise_from_response(response)
219220
return response
220221

@@ -236,7 +237,7 @@ def patch(
236237
timeout=self.timeout_seconds,
237238
)
238239
if self.verbose:
239-
self._last_response = DescopeResponse(response)
240+
self._thread_local.last_response = DescopeResponse(response)
240241
self._raise_from_response(response)
241242
return response
242243

@@ -256,7 +257,7 @@ def delete(
256257
timeout=self.timeout_seconds,
257258
)
258259
if self.verbose:
259-
self._last_response = DescopeResponse(response)
260+
self._thread_local.last_response = DescopeResponse(response)
260261
self._raise_from_response(response)
261262
return response
262263

@@ -267,6 +268,9 @@ def get_last_response(self) -> DescopeResponse | None:
267268
Useful for accessing HTTP metadata like headers (e.g., cf-ray),
268269
status codes, and raw response data for debugging.
269270
271+
This method is thread-safe: each thread will receive its own
272+
last response when using a shared client instance.
273+
270274
Returns:
271275
DescopeResponse: The last response if verbose mode is enabled, None otherwise.
272276
@@ -279,7 +283,7 @@ def get_last_response(self) -> DescopeResponse | None:
279283
if resp:
280284
logger.error(f"cf-ray: {resp.headers.get('cf-ray')}")
281285
"""
282-
return self._last_response
286+
return getattr(self._thread_local, "last_response", None)
283287

284288
def get_default_headers(self, pswd: str | None = None) -> dict:
285289
return self._get_default_headers(pswd)

tests/test_http_client.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,5 +419,137 @@ def __init__(self, version="0.0.0"):
419419
sys.modules.pop("pkg_resources", None)
420420

421421

422+
class TestVerboseModeThreadSafety(unittest.TestCase):
423+
"""Tests demonstrating verbose mode thread safety.
424+
425+
The HTTPClient uses threading.local() to store _last_response, ensuring
426+
each thread gets its own response when sharing a client instance.
427+
"""
428+
429+
@patch("requests.get")
430+
def test_verbose_mode_thread_safe_with_shared_client(self, mock_get):
431+
"""Verify that shared client is thread-safe for verbose mode.
432+
433+
Each thread should see its own response even when sharing the same
434+
HTTPClient instance, thanks to threading.local() storage.
435+
"""
436+
import threading
437+
438+
results: dict[str, str | None] = {
439+
"thread1_ray": None,
440+
"thread2_ray": None,
441+
}
442+
barrier = threading.Barrier(2)
443+
444+
def mock_get_side_effect(*args, **kwargs):
445+
"""Return different cf-ray based on which thread is calling."""
446+
thread_name = threading.current_thread().name
447+
response = Mock()
448+
response.ok = True
449+
response.json.return_value = {"thread": thread_name}
450+
# Each thread gets a unique cf-ray
451+
if "thread1" in thread_name:
452+
response.headers = {"cf-ray": "ray-thread1"}
453+
else:
454+
response.headers = {"cf-ray": "ray-thread2"}
455+
response.status_code = 200
456+
return response
457+
458+
mock_get.side_effect = mock_get_side_effect
459+
460+
# Single shared client - now thread-safe!
461+
client = HTTPClient(project_id="test123", verbose=True)
462+
463+
def thread1_work():
464+
client.get("/test")
465+
barrier.wait() # Sync with thread2
466+
resp = client.get_last_response()
467+
assert resp is not None
468+
results["thread1_ray"] = resp.headers.get("cf-ray")
469+
470+
def thread2_work():
471+
client.get("/test")
472+
barrier.wait() # Sync with thread1
473+
resp = client.get_last_response()
474+
assert resp is not None
475+
results["thread2_ray"] = resp.headers.get("cf-ray")
476+
477+
t1 = threading.Thread(target=thread1_work, name="thread1")
478+
t2 = threading.Thread(target=thread2_work, name="thread2")
479+
480+
t1.start()
481+
t2.start()
482+
t1.join()
483+
t2.join()
484+
485+
# With thread-local storage, each thread sees its OWN response
486+
assert (
487+
results["thread1_ray"] == "ray-thread1"
488+
), f"Thread1 should see its own cf-ray, got: {results['thread1_ray']}"
489+
assert (
490+
results["thread2_ray"] == "ray-thread2"
491+
), f"Thread2 should see its own cf-ray, got: {results['thread2_ray']}"
492+
493+
@patch("requests.get")
494+
def test_verbose_mode_separate_clients_per_thread(self, mock_get):
495+
"""Verify separate clients per thread also works (alternative pattern).
496+
497+
This test shows that using separate client instances per thread
498+
also provides thread-safe access to response metadata.
499+
"""
500+
import threading
501+
502+
results: dict[str, str | None] = {"thread1_ray": None, "thread2_ray": None}
503+
barrier = threading.Barrier(2)
504+
505+
def mock_get_side_effect(*args, **kwargs):
506+
thread_name = threading.current_thread().name
507+
response = Mock()
508+
response.ok = True
509+
response.json.return_value = {"thread": thread_name}
510+
if "thread1" in thread_name:
511+
response.headers = {"cf-ray": "ray-thread1"}
512+
else:
513+
response.headers = {"cf-ray": "ray-thread2"}
514+
response.status_code = 200
515+
return response
516+
517+
mock_get.side_effect = mock_get_side_effect
518+
519+
def thread1_work():
520+
# Each thread creates its own client
521+
client = HTTPClient(project_id="test123", verbose=True)
522+
client.get("/test")
523+
barrier.wait()
524+
resp = client.get_last_response()
525+
assert resp is not None
526+
results["thread1_ray"] = resp.headers.get("cf-ray")
527+
528+
def thread2_work():
529+
# Each thread creates its own client
530+
client = HTTPClient(project_id="test123", verbose=True)
531+
client.get("/test")
532+
barrier.wait()
533+
resp = client.get_last_response()
534+
assert resp is not None
535+
results["thread2_ray"] = resp.headers.get("cf-ray")
536+
537+
t1 = threading.Thread(target=thread1_work, name="thread1")
538+
t2 = threading.Thread(target=thread2_work, name="thread2")
539+
540+
t1.start()
541+
t2.start()
542+
t1.join()
543+
t2.join()
544+
545+
# With separate clients, each thread has its own response
546+
assert (
547+
results["thread1_ray"] == "ray-thread1"
548+
), f"Thread1 should see its own cf-ray, got: {results['thread1_ray']}"
549+
assert (
550+
results["thread2_ray"] == "ray-thread2"
551+
), f"Thread2 should see its own cf-ray, got: {results['thread2_ray']}"
552+
553+
422554
if __name__ == "__main__":
423555
unittest.main()

0 commit comments

Comments
 (0)