diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 451333993..bb00d9f3d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,7 +6,7 @@ repos: hooks: - id: sync_with_poetry - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 + rev: v6.0.0 hooks: - id: check-yaml - id: check-toml @@ -14,12 +14,12 @@ repos: - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/PyCQA/isort - rev: 6.1.0 + rev: 7.0.0 hooks: - id: isort args: ["--profile", "black"] - repo: https://github.com/psf/black - rev: 25.11.0 + rev: 25.12.0 hooks: - id: black language_version: python3 @@ -36,18 +36,21 @@ repos: - repo: https://github.com/python-poetry/poetry rev: 2.2.1 hooks: - - id: poetry-export - files: pyproject.toml - id: poetry-lock files: pyproject.toml - id: poetry-check files: pyproject.toml + - repo: https://github.com/python-poetry/poetry-plugin-export + rev: 1.9.0 + hooks: + - id: poetry-export + args: ["-f", "requirements.txt", "-o", "requirements.txt"] - repo: https://github.com/pre-commit/pre-commit - rev: v4.5.0 + rev: v4.5.1 hooks: - id: validate_manifest - repo: https://github.com/tox-dev/tox-ini-fmt - rev: 1.7.0 + rev: 1.7.1 hooks: - id: tox-ini-fmt args: ["-p", "type"] diff --git a/README.md b/README.md index 052872960..a95a0f2bb 100644 --- a/README.md +++ b/README.md @@ -536,6 +536,59 @@ descope_client = DescopeClient() descope_client = DescopeClient(project_id="", management_key="") ``` +### Verbose Mode for Debugging + +When debugging failed API requests, you can enable verbose mode to capture HTTP response metadata like headers (`cf-ray`, `x-request-id`), status codes, and raw response bodies. This is especially useful when working with Descope support to troubleshoot issues. + +```python +from descope import DescopeClient, AuthException +import logging + +logger = logging.getLogger(__name__) + +# Enable verbose mode during client initialization +client = DescopeClient( + project_id="", + management_key="", + verbose=True # Enable response metadata capture +) + +try: + # Make any API call + client.mgmt.user.create( + login_id="test@example.com", + email="test@example.com" + ) +except AuthException as e: + # Access the last response metadata for debugging + response = client.get_last_response() + if response: + logger.error(f"Request failed with status {response.status_code}") + logger.error(f"cf-ray: {response.headers.get('cf-ray')}") + logger.error(f"x-request-id: {response.headers.get('x-request-id')}") + logger.error(f"Response body: {response.text}") + + # Provide cf-ray to Descope support for debugging + print(f"Please provide this cf-ray to support: {response.headers.get('cf-ray')}") +``` + +**Important Notes:** +- Verbose mode is **disabled by default** (no performance impact when not needed) +- When enabled, only the **most recent** HTTP response is stored +- `get_last_response()` returns `None` when verbose mode is disabled +- The response object provides dict-like access to JSON data while also exposing HTTP metadata + +**Available metadata on response objects:** +- `response.headers` - HTTP response headers (dict-like object) +- `response.status_code` - HTTP status code (int) +- `response.text` - Raw response body as text (str) +- `response.url` - Request URL (str) +- `response.ok` - Whether status code is < 400 (bool) +- `response.json()` - Parsed JSON response (dict/list) +- `response["key"]` - Dict-like access to JSON data (for backward compatibility) + +For a complete example, see [samples/verbose_mode_example.py](https://github.com/descope/python-sdk/blob/main/samples/verbose_mode_example.py). + ### Manage Tenants You can create, update, delete or load tenants: diff --git a/descope/__init__.py b/descope/__init__.py index 7e71f2f6e..85090f525 100644 --- a/descope/__init__.py +++ b/descope/__init__.py @@ -17,6 +17,7 @@ AuthException, RateLimitException, ) +from descope.http_client import DescopeResponse from descope.management.common import ( AssociatedTenant, SAMLIDPAttributeMappingInfo, diff --git a/descope/descope_client.py b/descope/descope_client.py index 00852d974..610400901 100644 --- a/descope/descope_client.py +++ b/descope/descope_client.py @@ -1,7 +1,7 @@ from __future__ import annotations import os -from typing import Iterable, Optional +from typing import Iterable import requests @@ -27,15 +27,16 @@ class DescopeClient: def __init__( self, project_id: str, - public_key: Optional[dict] = None, + public_key: dict | None = None, skip_verify: bool = False, - management_key: Optional[str] = None, + management_key: str | None = None, timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, jwt_validation_leeway: int = 5, - auth_management_key: Optional[str] = None, - fga_cache_url: Optional[str] = None, + auth_management_key: str | None = None, + fga_cache_url: str | None = None, *, - base_url: Optional[str] = None, + base_url: str | None = None, + verbose: bool = False, ): # validate project id project_id = project_id or os.getenv("DESCOPE_PROJECT_ID", "") @@ -57,6 +58,7 @@ def __init__( secure=not skip_verify, management_key=auth_management_key or os.getenv("DESCOPE_AUTH_MANAGEMENT_KEY"), + verbose=verbose, ) self._auth = Auth( project_id, @@ -81,6 +83,7 @@ def __init__( timeout_seconds=auth_http_client.timeout_seconds, secure=auth_http_client.secure, management_key=management_key or os.getenv("DESCOPE_MANAGEMENT_KEY"), + verbose=verbose, ) self._mgmt = MGMT( http_client=mgmt_http_client, @@ -88,6 +91,10 @@ def __init__( 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 + @property def mgmt(self): return self._mgmt @@ -328,7 +335,7 @@ def get_matched_tenant_roles( return matched def validate_session( - self, session_token: str, audience: Optional[Iterable[str] | str] = None + self, session_token: str, audience: Iterable[str] | str | None = None ) -> dict: """ Validate a session token. Call this function for every incoming request to your @@ -351,7 +358,7 @@ def validate_session( return self._auth.validate_session(session_token, audience) def refresh_session( - self, refresh_token: str, audience: Optional[Iterable[str] | str] = None + self, refresh_token: str, audience: Iterable[str] | str | None = None ) -> dict: """ Refresh a session. Call this function when a session expires and needs to be refreshed. @@ -372,7 +379,7 @@ def validate_and_refresh_session( self, session_token: str, refresh_token: str, - audience: Optional[Iterable[str] | str] = None, + audience: Iterable[str] | str | None = None, ) -> dict: """ Validate the session token and refresh it if it has expired, the session token will automatically be refreshed. @@ -472,7 +479,7 @@ def my_tenants( self, refresh_token: str, dct: bool = False, - ids: Optional[list[str]] = None, + ids: list[str] | None = None, ) -> dict: """ Retrieve tenant attributes that user belongs to, one of dct/ids must be populated . @@ -553,8 +560,8 @@ def history(self, refresh_token: str) -> list[dict]: def exchange_access_key( self, access_key: str, - audience: Optional[Iterable[str] | str] = None, - login_options: Optional[AccessKeyLoginOptions] = None, + audience: Iterable[str] | str | None = None, + login_options: AccessKeyLoginOptions | None = None, ) -> dict: """ Return a new session token for the given access key @@ -595,3 +602,35 @@ def select_tenant( AuthException: Exception is raised if session is not authorized or another error occurs """ return self._auth.select_tenant(tenant_id, refresh_token) + + def get_last_response(self): + """ + Get the last HTTP response from either auth or management operations. + + Only available when verbose mode is enabled during client initialization. + This provides access to HTTP metadata like headers (cf-ray), status codes, + and raw response data for debugging failed requests. + + Returns: + DescopeResponse: The last response if verbose mode is enabled. + Returns the most recent response from either auth or mgmt operations. + None if verbose mode is disabled or no requests have been made. + + Example: + client = DescopeClient(project_id, management_key, verbose=True) + try: + client.mgmt.user.create(login_id="test@example.com") + except AuthException: + resp = client.get_last_response() + if resp: + # Access metadata for debugging + 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 diff --git a/descope/http_client.py b/descope/http_client.py index 540812c75..648e8179a 100644 --- a/descope/http_client.py +++ b/descope/http_client.py @@ -2,8 +2,9 @@ import os import platform +import threading from http import HTTPStatus -from typing import Optional, Union, cast +from typing import cast try: from importlib.metadata import version @@ -43,15 +44,114 @@ def sdk_version(): } +class DescopeResponse: + """ + Wrapper around requests.Response that provides dict-like access to JSON data + while preserving access to HTTP metadata (headers, status_code, etc.). + + This allows backward compatibility (acting like a dict) while exposing + HTTP metadata like cf-ray headers for debugging. + """ + + def __init__(self, response: requests.Response): + self.raw = response + self._json_data = None + + 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 + + # Dict-like interface for backward compatibility + def __getitem__(self, key): + return self.json()[key] + + def __contains__(self, key): + return key in self.json() + + def keys(self): + return self.json().keys() + + def values(self): + return self.json().values() + + def items(self): + return self.json().items() + + def get(self, key, default=None): + return self.json().get(key, default) + + def __str__(self): + return str(self.json()) + + def __repr__(self): + return f"DescopeResponse({repr(self.json())})" + + def __bool__(self): + return bool(self.json()) + + def __len__(self): + return len(self.json()) + + def __eq__(self, other): + if isinstance(other, DescopeResponse): + return self.json() == other.json() + return self.json() == other + + def __ne__(self, other): + return not self.__eq__(other) + + def __iter__(self): + return iter(self.json()) + + # HTTP metadata properties + @property + def headers(self): + """Access response headers (e.g., response.headers.get('cf-ray')).""" + return self.raw.headers + + @property + def status_code(self): + """HTTP status code.""" + return self.raw.status_code + + @property + def cookies(self): + """Response cookies.""" + return self.raw.cookies + + @property + def text(self): + """Raw response text.""" + return self.raw.text + + @property + def content(self): + """Raw response content (bytes).""" + return self.raw.content + + @property + def url(self): + """Request URL.""" + return self.raw.url + + @property + def ok(self): + """True if status code < 400.""" + return self.raw.ok + + class HTTPClient: def __init__( self, project_id: str, - base_url: Optional[str] = None, + base_url: str | None = None, *, timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, secure: bool = True, - management_key: Optional[str] = None, + management_key: str | None = None, + verbose: bool = False, ) -> None: if not project_id: raise AuthException( @@ -71,6 +171,8 @@ def __init__( self.timeout_seconds = timeout_seconds self.secure = secure self.management_key = management_key + self.verbose = verbose + self._thread_local = threading.local() # ------------- public API ------------- def get( @@ -78,8 +180,8 @@ def get( uri: str, *, params=None, - allow_redirects: Optional[bool] = True, - pswd: Optional[str] = None, + allow_redirects: bool | None = True, + pswd: str | None = None, ) -> requests.Response: response = requests.get( f"{self.base_url}{uri}", @@ -89,6 +191,8 @@ def get( verify=self.secure, timeout=self.timeout_seconds, ) + if self.verbose: + self._thread_local.last_response = DescopeResponse(response) self._raise_from_response(response) return response @@ -96,10 +200,10 @@ def post( self, uri: str, *, - body: Optional[Union[dict, list[dict], list[str]]] = None, + body: dict | list[dict] | list[str] | None = None, params=None, - pswd: Optional[str] = None, - base_url: Optional[str] = None, + pswd: str | None = None, + base_url: str | None = None, ) -> requests.Response: response = requests.post( f"{base_url or self.base_url}{uri}", @@ -110,6 +214,8 @@ def post( params=params, timeout=self.timeout_seconds, ) + if self.verbose: + self._thread_local.last_response = DescopeResponse(response) self._raise_from_response(response) return response @@ -117,9 +223,9 @@ def patch( self, uri: str, *, - body: Optional[Union[dict, list[dict], list[str]]], + body: dict | list[dict] | list[str] | None, params=None, - pswd: Optional[str] = None, + pswd: str | None = None, ) -> requests.Response: response = requests.patch( f"{self.base_url}{uri}", @@ -130,6 +236,8 @@ def patch( params=params, timeout=self.timeout_seconds, ) + if self.verbose: + self._thread_local.last_response = DescopeResponse(response) self._raise_from_response(response) return response @@ -138,7 +246,7 @@ def delete( uri: str, *, params=None, - pswd: Optional[str] = None, + pswd: str | None = None, ) -> requests.Response: response = requests.delete( f"{self.base_url}{uri}", @@ -148,10 +256,36 @@ def delete( verify=self.secure, timeout=self.timeout_seconds, ) + if self.verbose: + self._thread_local.last_response = DescopeResponse(response) self._raise_from_response(response) return response - def get_default_headers(self, pswd: Optional[str] = None) -> dict: + def get_last_response(self) -> DescopeResponse | None: + """ + Get the last HTTP response when verbose mode is enabled. + + Useful for accessing HTTP metadata like headers (e.g., cf-ray), + status codes, and raw response data for debugging. + + This method is thread-safe: each thread will receive its own + last response when using a shared client instance. + + Returns: + DescopeResponse: The last response if verbose mode is enabled, None otherwise. + + Example: + client = DescopeClient(project_id, management_key, verbose=True) + try: + client.mgmt.user.create(login_id="u1") + except AuthException: + resp = client.get_last_response() + if resp: + logger.error(f"cf-ray: {resp.headers.get('cf-ray')}") + """ + return getattr(self._thread_local, "last_response", None) + + def get_default_headers(self, pswd: str | None = None) -> dict: return self._get_default_headers(pswd) # ------------- helpers ------------- @@ -203,7 +337,7 @@ def _raise_from_response(self, response): response.text, ) - def _get_default_headers(self, pswd: Optional[str] = None): + def _get_default_headers(self, pswd: str | None = None): headers = _default_headers.copy() headers["x-descope-project-id"] = self.project_id bearer = self.project_id diff --git a/samples/verbose_mode_example.py b/samples/verbose_mode_example.py new file mode 100644 index 000000000..872597827 --- /dev/null +++ b/samples/verbose_mode_example.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +""" +Example demonstrating the verbose mode feature for capturing HTTP metadata. + +This is useful for debugging failed requests by accessing headers like cf-ray, +status codes, and raw response data. +""" + +import logging + +from descope import AuthException, DescopeClient + +# Setup logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +def example_with_verbose_mode(): + """Example showing how to use verbose mode to capture cf-ray headers for debugging.""" + + # Create client with verbose=True to enable response metadata capture + client = DescopeClient( + project_id="your-project-id", + management_key="your-management-key", + verbose=True, # Enable verbose mode + ) + + try: + # Make a management API call + client.mgmt.user.create( + login_id="test@example.com", + email="test@example.com", + display_name="Test User", + ) + + # Access the last response metadata + response = client.get_last_response() + if response: + logger.info("Request succeeded!") + logger.info("Status: %s", response.status_code) + logger.info("cf-ray: %s", response.headers.get("cf-ray")) + logger.info("x-request-id: %s", response.headers.get("x-request-id")) + + except AuthException: + # When an error occurs, capture the response metadata for debugging + response = client.get_last_response() + if response: + logger.error("Request failed with status %s", response.status_code) + logger.error("cf-ray: %s", response.headers.get("cf-ray")) + logger.error("x-request-id: %s", response.headers.get("x-request-id")) + logger.error("Response body: %s", response.text) + + # You can now provide cf-ray to Descope support for debugging + cf_ray = response.headers.get("cf-ray") + logger.info("Provide this cf-ray to support: %s", cf_ray) + + raise + + +def example_without_verbose_mode(): + """Example showing default behavior (no metadata captured).""" + + # Default: verbose=False (no metadata captured) + client = DescopeClient( + project_id="your-project-id", + management_key="your-management-key", + # verbose not specified, defaults to False + ) + + try: + client.mgmt.user.create(login_id="test@example.com", email="test@example.com") + + # get_last_response() returns None when verbose mode is disabled + response = client.get_last_response() + assert response is None + + except AuthException as exc: + logger.error("Request failed: %s", exc) + # No metadata available in default mode + response = client.get_last_response() + assert response is None + + +if __name__ == "__main__": + logger.info("Verbose mode examples:") + logger.info("\n1. With verbose mode (captures metadata):") + logger.info(" client = DescopeClient(project_id, management_key, verbose=True)") + logger.info(" # ... make API calls ...") + logger.info(" response = client.get_last_response()") + logger.info(" logger.info(response.headers.get('cf-ray'))") + + logger.info("\n2. Without verbose mode (default, no metadata):") + logger.info(" client = DescopeClient(project_id, management_key)") + logger.info(" # ... make API calls ...") + logger.info(" response = client.get_last_response() # Returns None") diff --git a/tests/test_descope_client.py b/tests/test_descope_client.py index c91ac87f4..26ce30d67 100644 --- a/tests/test_descope_client.py +++ b/tests/test_descope_client.py @@ -1080,6 +1080,53 @@ def test_base_url_none(self): self.assertEqual(client._auth.http_client.base_url, expected_base_url) self.assertEqual(client._mgmt._http.base_url, expected_base_url) + def test_verbose_mode_disabled_by_default(self): + """Test that verbose mode is disabled by default.""" + client = DescopeClient( + project_id=self.dummy_project_id, + public_key=self.public_key_dict, + ) + assert client.get_last_response() is None + + def test_verbose_mode_enabled(self): + """Test that verbose mode can be enabled.""" + client = DescopeClient( + project_id=self.dummy_project_id, + public_key=self.public_key_dict, + verbose=True, + ) + # Just verify it doesn't error when enabled + assert client.get_last_response() is None # No requests made yet + + @patch("requests.post") + def test_verbose_mode_captures_mgmt_response(self, mock_post): + """Test that management API responses are captured in verbose mode.""" + mock_response = mock.Mock() + mock_response.ok = True + mock_response.json.return_value = { + "user": {"id": "u1", "loginIds": ["test@example.com"]} + } + mock_response.headers = {"cf-ray": "mgmt-ray-123", "x-request-id": "req-456"} + mock_response.status_code = 200 + mock_post.return_value = mock_response + + client = DescopeClient( + project_id=self.dummy_project_id, + public_key=self.public_key_dict, + management_key="test-mgmt-key", + verbose=True, + ) + + # Make a management API call + client.mgmt.user.create(login_id="test@example.com") + + # Verify response was captured + last_resp = client.get_last_response() + assert last_resp is not None + assert last_resp["user"]["id"] == "u1" + assert last_resp.headers.get("cf-ray") == "mgmt-ray-123" + assert last_resp.status_code == 200 + if __name__ == "__main__": unittest.main() diff --git a/tests/test_http_client.py b/tests/test_http_client.py index bf610cbbd..bff4a761b 100644 --- a/tests/test_http_client.py +++ b/tests/test_http_client.py @@ -3,8 +3,161 @@ import sys import types import unittest +from unittest.mock import Mock, patch -from descope.http_client import HTTPClient +from descope.http_client import DescopeResponse, HTTPClient + + +class TestDescopeResponse(unittest.TestCase): + def test_dict_like_access(self): + """Test that DescopeResponse acts like a dict for backward compatibility.""" + mock_response = Mock() + mock_response.json.return_value = {"user": {"id": "u1"}, "status": "ok"} + mock_response.headers = {"cf-ray": "abc123"} + mock_response.status_code = 200 + + resp = DescopeResponse(mock_response) + + # Dict-like access + assert resp["user"]["id"] == "u1" + assert resp["status"] == "ok" + assert "user" in resp + assert "missing" not in resp + assert resp.get("status") == "ok" + assert resp.get("missing", "default") == "default" + assert list(resp.keys()) == ["user", "status"] + assert len(resp) == 2 + + def test_http_metadata_access(self): + """Test that HTTP metadata is accessible.""" + mock_response = Mock() + mock_response.json.return_value = {"result": "success"} + mock_response.headers = {"cf-ray": "abc123", "x-request-id": "req456"} + mock_response.status_code = 201 + mock_response.text = '{"result":"success"}' + mock_response.url = "https://api.descope.com/test" + mock_response.ok = True + + resp = DescopeResponse(mock_response) + + # HTTP metadata + assert resp.headers.get("cf-ray") == "abc123" + assert resp.headers.get("x-request-id") == "req456" + assert resp.status_code == 201 + assert resp.text == '{"result":"success"}' + assert resp.url == "https://api.descope.com/test" + assert resp.ok is True + + def test_json_caching(self): + """Test that JSON parsing is cached.""" + mock_response = Mock() + mock_response.json.return_value = {"data": "value"} + + resp = DescopeResponse(mock_response) + + # First call + result1 = resp.json() + # Second call should use cached value + result2 = resp.json() + + assert result1 == result2 + # json() should only be called once on the underlying response + assert mock_response.json.call_count == 1 + + def test_dict_like_values_items(self): + """Test that values() and items() work correctly.""" + mock_response = Mock() + mock_response.json.return_value = {"a": 1, "b": 2} + resp = DescopeResponse(mock_response) + + assert list(resp.values()) == [1, 2] + assert list(resp.items()) == [("a", 1), ("b", 2)] + + def test_string_representation(self): + """Test __str__ and __repr__ methods.""" + mock_response = Mock() + mock_response.json.return_value = {"result": "success"} + resp = DescopeResponse(mock_response) + + assert str(resp) == "{'result': 'success'}" + assert "DescopeResponse" in repr(resp) + + def test_bool_and_len(self): + """Test __bool__ and __len__ methods.""" + mock_response = Mock() + mock_response.json.return_value = {"data": "value"} + resp = DescopeResponse(mock_response) + + assert bool(resp) is True + assert len(resp) == 1 + + def test_equality(self): + """Test __eq__ and __ne__ methods.""" + mock1 = Mock() + mock1.json.return_value = {"data": "value"} + mock2 = Mock() + mock2.json.return_value = {"data": "value"} + mock3 = Mock() + mock3.json.return_value = {"different": "data"} + + resp1 = DescopeResponse(mock1) + resp2 = DescopeResponse(mock2) + resp3 = DescopeResponse(mock3) + + assert resp1 == resp2 + assert resp1 != resp3 + assert resp1 == {"data": "value"} + + def test_iter(self): + """Test __iter__ method.""" + mock_response = Mock() + mock_response.json.return_value = {"a": 1, "b": 2} + resp = DescopeResponse(mock_response) + + assert list(resp) == ["a", "b"] + + def test_cookies_and_content(self): + """Test cookies and content properties.""" + mock_response = Mock() + mock_response.json.return_value = {"data": "test"} + mock_response.cookies = {"session": "abc123"} + mock_response.content = b'{"data":"test"}' + resp = DescopeResponse(mock_response) + + assert resp.cookies.get("session") == "abc123" + assert resp.content == b'{"data":"test"}' + + @patch("requests.get") + def test_verbose_mode_captures_response_before_error(self, mock_get): + """Test that verbose mode captures response even when errors are raised. + + This is critical for debugging - the whole point of verbose mode is to + capture headers (cf-ray) from failed requests to share with support. + """ + from descope.exceptions import AuthException + + mock_response = Mock() + mock_response.ok = False + mock_response.status_code = 401 + mock_response.text = "Unauthorized" + mock_response.headers = {"cf-ray": "error123"} + mock_response.json.return_value = {"error": "Unauthorized"} + mock_get.return_value = mock_response + + client = HTTPClient(project_id="test123", verbose=True) + try: + client.get("/test") + assert False, "Should have raised AuthException" + except AuthException: + pass + + last_resp = client.get_last_response() + assert ( + last_resp is not None + ), "Response should be captured even when error occurs" + assert last_resp.status_code == 401 + assert last_resp.headers.get("cf-ray") == "error123" + assert last_resp.text == "Unauthorized" class TestHTTPClient(unittest.TestCase): @@ -15,6 +168,216 @@ def test_base_url_for_project_id(self): pid = "Puse12aAc4T2V93bddihGEx2Ryhc8e5Z" assert HTTPClient.base_url_for_project_id(pid) == "https://api.use1.descope.com" + def test_verbose_mode_disabled_by_default(self): + """Test that verbose mode is disabled by default.""" + client = HTTPClient(project_id="test123") + assert client.verbose is False + assert client.get_last_response() is None + + def test_verbose_mode_enabled(self): + """Test that verbose mode can be enabled.""" + client = HTTPClient(project_id="test123", verbose=True) + assert client.verbose is True + + @patch("requests.get") + def test_verbose_mode_captures_response(self, mock_get): + """Test that responses are captured when verbose mode is enabled.""" + # Setup mock response + mock_response = Mock() + mock_response.ok = True + mock_response.json.return_value = {"data": "test"} + mock_response.headers = {"cf-ray": "xyz789"} + mock_response.status_code = 200 + mock_get.return_value = mock_response + + # Create client with verbose mode + client = HTTPClient(project_id="test123", verbose=True) + + # Make a request + client.get("/test") + + # Verify response was captured + last_resp = client.get_last_response() + assert last_resp is not None + assert last_resp["data"] == "test" + assert last_resp.headers.get("cf-ray") == "xyz789" + assert last_resp.status_code == 200 + + @patch("requests.get") + def test_verbose_mode_not_capture_when_disabled(self, mock_get): + """Test that responses are NOT captured when verbose mode is disabled.""" + mock_response = Mock() + mock_response.ok = True + mock_response.json.return_value = {"data": "test"} + mock_get.return_value = mock_response + + # Create client WITHOUT verbose mode + client = HTTPClient(project_id="test123", verbose=False) + + # Make a request + client.get("/test") + + # Verify response was NOT captured + assert client.get_last_response() is None + + @patch("requests.post") + def test_verbose_mode_captures_post_response(self, mock_post): + """Test that POST responses are captured in verbose mode.""" + mock_response = Mock() + mock_response.ok = True + mock_response.json.return_value = {"created": "user1"} + mock_response.headers = {"cf-ray": "post123"} + mock_response.status_code = 201 + mock_post.return_value = mock_response + + client = HTTPClient(project_id="test123", verbose=True) + client.post("/users", body={"name": "test"}) + + last_resp = client.get_last_response() + assert last_resp is not None + assert last_resp["created"] == "user1" + assert last_resp.status_code == 201 + + @patch("requests.patch") + def test_verbose_mode_captures_patch_response(self, mock_patch): + """Test that PATCH responses are captured in verbose mode.""" + mock_response = Mock() + mock_response.ok = True + mock_response.json.return_value = {"updated": "user1"} + mock_response.headers = {"cf-ray": "patch123"} + mock_response.status_code = 200 + mock_patch.return_value = mock_response + + client = HTTPClient(project_id="test123", verbose=True) + client.patch("/users/1", body={"name": "updated"}) + + last_resp = client.get_last_response() + assert last_resp is not None + assert last_resp["updated"] == "user1" + assert last_resp.status_code == 200 + + @patch("requests.delete") + def test_verbose_mode_captures_delete_response(self, mock_delete): + """Test that DELETE responses are captured in verbose mode.""" + mock_response = Mock() + mock_response.ok = True + mock_response.json.return_value = {"deleted": "user1"} + mock_response.headers = {"cf-ray": "delete123"} + mock_response.status_code = 204 + mock_delete.return_value = mock_response + + client = HTTPClient(project_id="test123", verbose=True) + client.delete("/users/1") + + last_resp = client.get_last_response() + assert last_resp is not None + assert last_resp["deleted"] == "user1" + assert last_resp.status_code == 204 + + def test_raises_auth_exception_with_empty_project_id(self): + """Test that HTTPClient raises AuthException when project_id is empty.""" + from descope.exceptions import AuthException + + with self.assertRaises(AuthException) as cm: + HTTPClient(project_id="") + + assert cm.exception.status_code == 400 + + @patch("requests.get") + def test_raises_rate_limit_exception(self, mock_get): + """Test that HTTPClient raises RateLimitException on 429.""" + from descope.exceptions import RateLimitException + + mock_response = Mock() + mock_response.ok = False + mock_response.status_code = 429 + mock_response.json.return_value = { + "errorCode": "E010", + "errorDescription": "Rate limit exceeded", + "errorMessage": "Too many requests", + } + mock_response.headers = {"Retry-After": "60"} + mock_get.return_value = mock_response + + client = HTTPClient(project_id="test123") + + with self.assertRaises(RateLimitException) as cm: + client.get("/test") + + assert cm.exception.error_type == "API rate limit exceeded" + + @patch("requests.get") + def test_raises_rate_limit_exception_without_json_body(self, mock_get): + """Test that RateLimitException is raised even when JSON parsing fails.""" + from descope.exceptions import RateLimitException + + mock_response = Mock() + mock_response.ok = False + mock_response.status_code = 429 + mock_response.json.side_effect = ValueError("Invalid JSON") + mock_response.headers = {"Retry-After": "30"} + mock_get.return_value = mock_response + + client = HTTPClient(project_id="test123") + + with self.assertRaises(RateLimitException) as cm: + client.get("/test") + + assert cm.exception.error_type == "API rate limit exceeded" + + @patch("requests.get") + def test_raises_auth_exception_on_server_error(self, mock_get): + """Test that HTTPClient raises AuthException on 500.""" + from descope.exceptions import AuthException + + mock_response = Mock() + mock_response.ok = False + mock_response.status_code = 500 + mock_response.text = "Internal Server Error" + mock_get.return_value = mock_response + + client = HTTPClient(project_id="test123") + + with self.assertRaises(AuthException) as cm: + client.get("/test") + + assert cm.exception.status_code == 500 + + def test_get_default_headers_with_password(self): + """Test get_default_headers with password.""" + client = HTTPClient(project_id="test123") + headers = client.get_default_headers("mypassword") + assert "Authorization" in headers + assert "test123:mypassword" in headers["Authorization"] + + def test_get_default_headers_with_management_key(self): + """Test get_default_headers with management key.""" + client = HTTPClient(project_id="test123", management_key="mgmt-key") + headers = client.get_default_headers() + assert "Authorization" in headers + assert "test123:mgmt-key" in headers["Authorization"] + + def test_parse_retry_after_with_valid_header(self): + """Test _parse_retry_after with valid header.""" + client = HTTPClient(project_id="test123") + headers = {"Retry-After": "60"} + result = client._parse_retry_after(headers) + assert result == 60 + + def test_parse_retry_after_with_missing_header(self): + """Test _parse_retry_after with missing header.""" + client = HTTPClient(project_id="test123") + headers = {} + result = client._parse_retry_after(headers) + assert result == 0 + + def test_parse_retry_after_with_invalid_header(self): + """Test _parse_retry_after with invalid header.""" + client = HTTPClient(project_id="test123") + headers = {"Retry-After": "not-a-number"} + result = client._parse_retry_after(headers) + assert result == 0 + @unittest.skipIf( importlib.util.find_spec("importlib.metadata") is not None, "Stdlib metadata available; skip fallback path test", @@ -38,7 +401,7 @@ def __init__(self, version="0.0.0"): self.version = version fake_pkg = types.ModuleType("pkg_resources") - fake_pkg.get_distribution = lambda name: FakeDist("9.9.9") + fake_pkg.get_distribution = lambda name: FakeDist("9.9.9") # type: ignore saved_pkg = sys.modules.get("pkg_resources") sys.modules["pkg_resources"] = fake_pkg @@ -56,5 +419,137 @@ def __init__(self, version="0.0.0"): sys.modules.pop("pkg_resources", None) +class TestVerboseModeThreadSafety(unittest.TestCase): + """Tests demonstrating verbose mode thread safety. + + The HTTPClient uses threading.local() to store _last_response, ensuring + each thread gets its own response when sharing a client instance. + """ + + @patch("requests.get") + def test_verbose_mode_thread_safe_with_shared_client(self, mock_get): + """Verify that shared client is thread-safe for verbose mode. + + Each thread should see its own response even when sharing the same + HTTPClient instance, thanks to threading.local() storage. + """ + import threading + + results: dict[str, str | None] = { + "thread1_ray": None, + "thread2_ray": None, + } + barrier = threading.Barrier(2) + + def mock_get_side_effect(*args, **kwargs): + """Return different cf-ray based on which thread is calling.""" + thread_name = threading.current_thread().name + response = Mock() + response.ok = True + response.json.return_value = {"thread": thread_name} + # Each thread gets a unique cf-ray + if "thread1" in thread_name: + response.headers = {"cf-ray": "ray-thread1"} + else: + response.headers = {"cf-ray": "ray-thread2"} + response.status_code = 200 + return response + + mock_get.side_effect = mock_get_side_effect + + # Single shared client - now thread-safe! + client = HTTPClient(project_id="test123", verbose=True) + + def thread1_work(): + client.get("/test") + barrier.wait() # Sync with thread2 + resp = client.get_last_response() + assert resp is not None + results["thread1_ray"] = resp.headers.get("cf-ray") + + def thread2_work(): + client.get("/test") + barrier.wait() # Sync with thread1 + resp = client.get_last_response() + assert resp is not None + results["thread2_ray"] = resp.headers.get("cf-ray") + + t1 = threading.Thread(target=thread1_work, name="thread1") + t2 = threading.Thread(target=thread2_work, name="thread2") + + t1.start() + t2.start() + t1.join() + t2.join() + + # With thread-local storage, each thread sees its OWN response + assert ( + results["thread1_ray"] == "ray-thread1" + ), f"Thread1 should see its own cf-ray, got: {results['thread1_ray']}" + assert ( + results["thread2_ray"] == "ray-thread2" + ), f"Thread2 should see its own cf-ray, got: {results['thread2_ray']}" + + @patch("requests.get") + def test_verbose_mode_separate_clients_per_thread(self, mock_get): + """Verify separate clients per thread also works (alternative pattern). + + This test shows that using separate client instances per thread + also provides thread-safe access to response metadata. + """ + import threading + + results: dict[str, str | None] = {"thread1_ray": None, "thread2_ray": None} + barrier = threading.Barrier(2) + + def mock_get_side_effect(*args, **kwargs): + thread_name = threading.current_thread().name + response = Mock() + response.ok = True + response.json.return_value = {"thread": thread_name} + if "thread1" in thread_name: + response.headers = {"cf-ray": "ray-thread1"} + else: + response.headers = {"cf-ray": "ray-thread2"} + response.status_code = 200 + return response + + mock_get.side_effect = mock_get_side_effect + + def thread1_work(): + # Each thread creates its own client + client = HTTPClient(project_id="test123", verbose=True) + client.get("/test") + barrier.wait() + resp = client.get_last_response() + assert resp is not None + results["thread1_ray"] = resp.headers.get("cf-ray") + + def thread2_work(): + # Each thread creates its own client + client = HTTPClient(project_id="test123", verbose=True) + client.get("/test") + barrier.wait() + resp = client.get_last_response() + assert resp is not None + results["thread2_ray"] = resp.headers.get("cf-ray") + + t1 = threading.Thread(target=thread1_work, name="thread1") + t2 = threading.Thread(target=thread2_work, name="thread2") + + t1.start() + t2.start() + t1.join() + t2.join() + + # With separate clients, each thread has its own response + assert ( + results["thread1_ray"] == "ray-thread1" + ), f"Thread1 should see its own cf-ray, got: {results['thread1_ray']}" + assert ( + results["thread2_ray"] == "ray-thread2" + ), f"Thread2 should see its own cf-ray, got: {results['thread2_ray']}" + + if __name__ == "__main__": unittest.main()