Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 59 additions & 4 deletions descope/_http_client_base.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
# This is not part of the public API but a code helper
from __future__ import annotations

import contextvars
import os
import platform
import ssl
import threading
from functools import cached_property
from http import HTTPStatus
from importlib.metadata import version

Expand Down Expand Up @@ -59,19 +62,34 @@ class DescopeResponse:
raise on a non-JSON body. Inspecting the response itself never does:
``bool()`` is always True, and ``str()``/``repr()`` fall back to the raw
text, so a response is always loggable. Use ``is_json`` to check first.

Body parsing is cached, since the wrapped response is already complete:
``_json_data`` and ``is_json`` are ``cached_property``. A failed parse is not
cached — ``cached_property`` stores nothing when the getter raises — so a
non-JSON body keeps raising from ``json()`` rather than caching a sentinel.
``functools.cache`` is not usable here: it keys on ``self``, which is
unhashable (``__eq__`` without ``__hash__``) and would be pinned alive by the
module-level cache.

The HTTP metadata accessors below stay plain properties on purpose. httpx
already caches ``text``/``content``/``cookies`` internally, the rest are
attribute reads, and on Python 3.9-3.11 ``cached_property`` takes a
descriptor-wide lock on first access — so caching them would cost more than
it saves and would make them assignable.
"""

def __init__(self, response: httpx.Response):
self.raw = response
self._json_data = None

@cached_property
def _json_data(self):
return self.raw.json()

def json(self):
"""Get the parsed JSON response, cached after first access."""
if self._json_data is None:
self._json_data = self.raw.json()
return self._json_data

@property
@cached_property
def is_json(self) -> bool:
"""True if the response body can be parsed as JSON."""
try:
Expand Down Expand Up @@ -180,6 +198,43 @@ def ok(self):
return self.raw.is_success


class ThreadLocalLastResponseStore:
"""One last-response slot, isolated per thread.

Shared by every ``HTTPClient`` a ``DescopeClient`` owns, so "last" means the
most recent response across auth and management calls rather than per-client.
"""

def __init__(self) -> None:
self._local = threading.local()

def set(self, response: DescopeResponse) -> None:
self._local.last_response = response

def get(self) -> DescopeResponse | None:
return getattr(self._local, "last_response", None)


class ContextVarLastResponseStore:
"""One last-response slot, isolated per async task.

ContextVar rather than threading.local: every asyncio task runs on the same
event-loop thread, so a thread-local slot would be a single slot shared by
all concurrent tasks.
"""

def __init__(self) -> None:
self._var: contextvars.ContextVar[DescopeResponse | None] = contextvars.ContextVar(
"descope_async_last_response", default=None
)

def set(self, response: DescopeResponse) -> None:
self._var.set(response)

def get(self) -> DescopeResponse | None:
return self._var.get()


class HTTPClientBase:
"""Shared, I/O-free base for HTTP client classes.

Expand Down
18 changes: 10 additions & 8 deletions descope/descope_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import httpx

from descope._client_base import DescopeClientBase
from descope._http_client_base import ThreadLocalLastResponseStore
from descope.auth import Auth
from descope.authmethod.enchantedlink import EnchantedLink # noqa: F401
from descope.authmethod.magiclink import MagicLink # noqa: F401
Expand Down Expand Up @@ -54,13 +55,18 @@ def __init__(
base_url=base_url,
verbose=verbose,
)
# One store shared by every HTTP client below, so get_last_response()
# returns the genuinely most recent response rather than picking between
# per-client slots that were overwritten independently.
self._last_response_store = ThreadLocalLastResponseStore()
auth_http_client = HTTPClient(
project_id=self._project_id,
base_url=base_url,
timeout_seconds=timeout_seconds,
secure=not skip_verify,
management_key=auth_management_key or os.getenv("DESCOPE_AUTH_MANAGEMENT_KEY"),
verbose=verbose,
last_response_store=self._last_response_store,
)
self._auth = Auth(
self._project_id,
Expand All @@ -87,6 +93,7 @@ def __init__(
secure=auth_http_client.secure,
management_key=management_key or os.getenv("DESCOPE_MANAGEMENT_KEY"),
verbose=verbose,
last_response_store=self._last_response_store,
)
self._mgmt = MGMT(
http_client=mgmt_http_client,
Expand Down Expand Up @@ -378,7 +385,8 @@ def get_last_response(self):

Returns:
DescopeResponse: The last response if verbose mode is enabled.
Returns the most recent response from either auth or mgmt operations.
Returns the most recent response across auth and mgmt
operations, whichever ran last.
None if verbose mode is disabled or no requests have been made.

Example:
Expand All @@ -392,10 +400,4 @@ def get_last_response(self):
cf_ray = resp.headers.get("cf-ray")
status = resp.status_code
"""
# Return the most recently used response
mgmt_resp = self._mgmt_http_client.get_last_response()
auth_resp = self._auth_http_client.get_last_response()

# Return whichever is not None, preferring mgmt if both exist
# (in practice, only one should be non-None at a time)
return mgmt_resp or auth_resp
return self._last_response_store.get()
17 changes: 13 additions & 4 deletions descope/descope_client_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import httpx

from descope._client_base import DescopeClientBase
from descope._http_client_base import ContextVarLastResponseStore
from descope.auth_async import AuthAsync
from descope.authmethod.enchantedlink_async import EnchantedLinkAsync
from descope.authmethod.magiclink_async import MagicLinkAsync
Expand Down Expand Up @@ -87,13 +88,18 @@ def __init__(
verbose=verbose,
)

# One store shared by every HTTP client below, so get_last_response()
# returns the genuinely most recent response rather than picking between
# per-client slots that were overwritten independently.
self._last_response_store = ContextVarLastResponseStore()
self._auth_http = HTTPClientAsync(
project_id=self._project_id,
base_url=base_url,
timeout_seconds=timeout_seconds,
secure=not skip_verify,
management_key=auth_management_key or os.getenv("DESCOPE_AUTH_MANAGEMENT_KEY"),
verbose=verbose,
last_response_store=self._last_response_store,
)
self._mgmt_http = HTTPClientAsync(
project_id=self._project_id,
Expand All @@ -102,6 +108,7 @@ def __init__(
secure=not skip_verify,
management_key=management_key or os.getenv("DESCOPE_MANAGEMENT_KEY"),
verbose=verbose,
last_response_store=self._last_response_store,
)
self._auth = AuthAsync(
self._project_id,
Expand Down Expand Up @@ -319,7 +326,9 @@ async def select_tenant(self, tenant_id: str, refresh_token: str) -> dict:
return await self._auth.select_tenant(tenant_id, refresh_token)

def get_last_response(self):
"""Get the last HTTP response when verbose mode is enabled."""
mgmt_resp = self._mgmt_http.get_last_response()
auth_resp = self._auth_http.get_last_response()
return mgmt_resp or auth_resp
"""Get the last HTTP response when verbose mode is enabled.

Returns the most recent response across auth and mgmt operations,
whichever ran last.
"""
return self._last_response_store.get()
23 changes: 16 additions & 7 deletions descope/http_client.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from __future__ import annotations

import threading
import time
from typing import cast

Expand All @@ -12,6 +11,7 @@
DEFAULT_TIMEOUT_SECONDS,
DescopeResponse,
HTTPClientBase,
ThreadLocalLastResponseStore,
)


Expand All @@ -25,6 +25,7 @@ def __init__(
secure: bool = True,
management_key: str | None = None,
verbose: bool = False,
last_response_store: ThreadLocalLastResponseStore | None = None,
) -> None:
super().__init__(
project_id,
Expand All @@ -34,7 +35,9 @@ def __init__(
management_key=management_key,
verbose=verbose,
)
self._thread_local = threading.local()
# Shared by every client of one DescopeClient when passed in, so
# get_last_response() sees a single ordering across auth and mgmt.
self.last_response_store = last_response_store or ThreadLocalLastResponseStore()

# ------------- public API -------------
def get(
Expand All @@ -56,7 +59,7 @@ def get(
)
)
if self.verbose:
self._thread_local.last_response = DescopeResponse(response)
self.last_response_store.set(DescopeResponse(response))
self._raise_from_response(response)
return response

Expand All @@ -81,7 +84,7 @@ def post(
)
)
if self.verbose:
self._thread_local.last_response = DescopeResponse(response)
self.last_response_store.set(DescopeResponse(response))
self._raise_from_response(response)
return response

Expand All @@ -104,6 +107,8 @@ def put(
timeout=self.timeout_seconds,
)
)
if self.verbose:
self.last_response_store.set(DescopeResponse(response))
self._raise_from_response(response)
return response

Expand All @@ -127,7 +132,7 @@ def patch(
)
)
if self.verbose:
self._thread_local.last_response = DescopeResponse(response)
self.last_response_store.set(DescopeResponse(response))
self._raise_from_response(response)
return response

Expand All @@ -149,7 +154,7 @@ def delete(
)
)
if self.verbose:
self._thread_local.last_response = DescopeResponse(response)
self.last_response_store.set(DescopeResponse(response))
self._raise_from_response(response)
return response

Expand All @@ -163,6 +168,10 @@ def get_last_response(self) -> DescopeResponse | None:
This method is thread-safe: each thread will receive its own
last response when using a shared client instance.

When the store is shared with other clients — as ``DescopeClient`` does
for its auth and management clients — this reports the last response
across all of them, not just the ones this client issued.

Returns:
DescopeResponse: The last response if verbose mode is enabled, None otherwise.

Expand All @@ -175,7 +184,7 @@ def get_last_response(self) -> DescopeResponse | None:
if resp:
logger.error(f"cf-ray: {resp.headers.get('cf-ray')}")
"""
return getattr(self._thread_local, "last_response", None)
return self.last_response_store.get()

# ------------- helpers -------------
def _execute_with_retry(self, request_fn) -> httpx.Response:
Expand Down
25 changes: 16 additions & 9 deletions descope/http_client_async.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from __future__ import annotations

import asyncio
import contextvars
from typing import Awaitable, Callable, cast

import httpx
Expand All @@ -10,6 +9,7 @@
_RETRY_DELAYS_SECONDS,
_RETRY_STATUS_CODES,
DEFAULT_TIMEOUT_SECONDS,
ContextVarLastResponseStore,
DescopeResponse,
HTTPClientBase,
)
Expand All @@ -25,6 +25,7 @@ def __init__(
secure: bool = True,
management_key: str | None = None,
verbose: bool = False,
last_response_store: ContextVarLastResponseStore | None = None,
) -> None:
super().__init__(
project_id,
Expand All @@ -38,9 +39,9 @@ def __init__(
verify=self.client_verify,
timeout=self.timeout_seconds,
)
self._last_response_var: contextvars.ContextVar[DescopeResponse | None] = contextvars.ContextVar(
"descope_async_last_response", default=None
)
# Shared by every client of one DescopeClientAsync when passed in, so
# get_last_response() sees a single ordering across auth and mgmt.
self.last_response_store = last_response_store or ContextVarLastResponseStore()
# Optional one-shot async hook invoked before the first request goes
# out. Used by ``DescopeClientAsync`` to lazily run the license
# handshake on ``_mgmt_http`` without blocking the event loop in
Expand All @@ -65,7 +66,7 @@ async def get(
)
)
if self.verbose:
self._last_response_var.set(DescopeResponse(response))
self.last_response_store.set(DescopeResponse(response))
self._raise_from_response(response)
return response

Expand All @@ -88,7 +89,7 @@ async def post(
)
)
if self.verbose:
self._last_response_var.set(DescopeResponse(response))
self.last_response_store.set(DescopeResponse(response))
self._raise_from_response(response)
return response

Expand All @@ -109,6 +110,8 @@ async def put(
params=params,
)
)
if self.verbose:
self.last_response_store.set(DescopeResponse(response))
self._raise_from_response(response)
return response

Expand All @@ -130,7 +133,7 @@ async def patch(
)
)
if self.verbose:
self._last_response_var.set(DescopeResponse(response))
self.last_response_store.set(DescopeResponse(response))
self._raise_from_response(response)
return response

Expand All @@ -150,7 +153,7 @@ async def delete(
)
)
if self.verbose:
self._last_response_var.set(DescopeResponse(response))
self.last_response_store.set(DescopeResponse(response))
self._raise_from_response(response)
return response

Expand All @@ -160,8 +163,12 @@ def get_last_response(self) -> DescopeResponse | None:

Uses a ContextVar (not threading.local) so each concurrent async task sees its
own last response, even though all tasks share one event-loop thread.

When the store is shared with other clients — as ``DescopeClientAsync`` does
for its auth and management clients — this reports the last response across
all of them, not just the ones this client issued.
"""
return self._last_response_var.get()
return self.last_response_store.get()

async def _async_execute_with_retry(self, request_fn) -> httpx.Response:
if self._pre_request_hook is not None:
Expand Down
Loading
Loading