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
45 changes: 41 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 @@ -63,15 +66,16 @@ class DescopeResponse:

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

@cached_property
Comment thread
LioriE marked this conversation as resolved.
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 +184,39 @@ def ok(self):
return self.raw.is_success


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

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
16 changes: 7 additions & 9 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,15 @@ def __init__(
base_url=base_url,
verbose=verbose,
)
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,14 +90,14 @@ 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,
auth=self._auth,
fga_cache_url=fga_cache_url,
)

# Store references to HTTP clients for verbose mode access
self._auth_http_client = auth_http_client
self._mgmt_http_client = mgmt_http_client

Expand Down Expand Up @@ -378,7 +381,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 +396,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()
14 changes: 10 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,15 @@ def __init__(
verbose=verbose,
)

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 +105,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 +323,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()
17 changes: 10 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,7 @@ def __init__(
management_key=management_key,
verbose=verbose,
)
self._thread_local = threading.local()
self.last_response_store = last_response_store or ThreadLocalLastResponseStore()

# ------------- public API -------------
def get(
Expand All @@ -56,7 +57,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 +82,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 +105,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 +130,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 +152,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 @@ -175,7 +178,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
19 changes: 10 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,7 @@ 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
)
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 +64,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 +87,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 +108,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 +131,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 +151,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 @@ -161,7 +162,7 @@ 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.
"""
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
2 changes: 2 additions & 0 deletions descope/management/outbound_application.py
Original file line number Diff line number Diff line change
Expand Up @@ -729,6 +729,8 @@ def __init__(self, http_client: HTTPClient):
timeout_seconds=http_client.timeout_seconds,
secure=http_client.secure,
management_key=None, # Override the management key for this client
verbose=http_client.verbose,
last_response_store=http_client.last_response_store,
Comment thread
LioriE marked this conversation as resolved.
)
super().__init__(no_key_client)

Expand Down
2 changes: 2 additions & 0 deletions descope/management/outbound_application_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -729,6 +729,8 @@ def __init__(self, http_client: HTTPClientAsync):
timeout_seconds=http_client.timeout_seconds,
secure=http_client.secure,
management_key=None, # Override the management key for this client
verbose=http_client.verbose,
last_response_store=http_client.last_response_store,
)
super().__init__(no_key_client)

Expand Down
Loading
Loading