Skip to content

Commit 91b364a

Browse files
authored
feat: add opt-in verbose mode for capturing HTTP response metadata (#718)
1 parent cc5462e commit 91b364a

File tree

8 files changed

+901
-34
lines changed

8 files changed

+901
-34
lines changed

.pre-commit-config.yaml

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,20 @@ repos:
66
hooks:
77
- id: sync_with_poetry
88
- repo: https://github.com/pre-commit/pre-commit-hooks
9-
rev: v5.0.0
9+
rev: v6.0.0
1010
hooks:
1111
- id: check-yaml
1212
- id: check-toml
1313
- id: debug-statements
1414
- id: end-of-file-fixer
1515
- id: trailing-whitespace
1616
- repo: https://github.com/PyCQA/isort
17-
rev: 6.1.0
17+
rev: 7.0.0
1818
hooks:
1919
- id: isort
2020
args: ["--profile", "black"]
2121
- repo: https://github.com/psf/black
22-
rev: 25.11.0
22+
rev: 25.12.0
2323
hooks:
2424
- id: black
2525
language_version: python3
@@ -36,18 +36,21 @@ repos:
3636
- repo: https://github.com/python-poetry/poetry
3737
rev: 2.2.1
3838
hooks:
39-
- id: poetry-export
40-
files: pyproject.toml
4139
- id: poetry-lock
4240
files: pyproject.toml
4341
- id: poetry-check
4442
files: pyproject.toml
43+
- repo: https://github.com/python-poetry/poetry-plugin-export
44+
rev: 1.9.0
45+
hooks:
46+
- id: poetry-export
47+
args: ["-f", "requirements.txt", "-o", "requirements.txt"]
4548
- repo: https://github.com/pre-commit/pre-commit
46-
rev: v4.5.0
49+
rev: v4.5.1
4750
hooks:
4851
- id: validate_manifest
4952
- repo: https://github.com/tox-dev/tox-ini-fmt
50-
rev: 1.7.0
53+
rev: 1.7.1
5154
hooks:
5255
- id: tox-ini-fmt
5356
args: ["-p", "type"]

README.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -536,6 +536,59 @@ descope_client = DescopeClient()
536536
descope_client = DescopeClient(project_id="<Project ID>", management_key="<Management Key>")
537537
```
538538

539+
### Verbose Mode for Debugging
540+
541+
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.
542+
543+
```python
544+
from descope import DescopeClient, AuthException
545+
import logging
546+
547+
logger = logging.getLogger(__name__)
548+
549+
# Enable verbose mode during client initialization
550+
client = DescopeClient(
551+
project_id="<Project ID>",
552+
management_key="<Management Key>",
553+
verbose=True # Enable response metadata capture
554+
)
555+
556+
try:
557+
# Make any API call
558+
client.mgmt.user.create(
559+
login_id="[email protected]",
560+
561+
)
562+
except AuthException as e:
563+
# Access the last response metadata for debugging
564+
response = client.get_last_response()
565+
if response:
566+
logger.error(f"Request failed with status {response.status_code}")
567+
logger.error(f"cf-ray: {response.headers.get('cf-ray')}")
568+
logger.error(f"x-request-id: {response.headers.get('x-request-id')}")
569+
logger.error(f"Response body: {response.text}")
570+
571+
# Provide cf-ray to Descope support for debugging
572+
print(f"Please provide this cf-ray to support: {response.headers.get('cf-ray')}")
573+
```
574+
575+
**Important Notes:**
576+
- Verbose mode is **disabled by default** (no performance impact when not needed)
577+
- When enabled, only the **most recent** HTTP response is stored
578+
- `get_last_response()` returns `None` when verbose mode is disabled
579+
- The response object provides dict-like access to JSON data while also exposing HTTP metadata
580+
581+
**Available metadata on response objects:**
582+
- `response.headers` - HTTP response headers (dict-like object)
583+
- `response.status_code` - HTTP status code (int)
584+
- `response.text` - Raw response body as text (str)
585+
- `response.url` - Request URL (str)
586+
- `response.ok` - Whether status code is < 400 (bool)
587+
- `response.json()` - Parsed JSON response (dict/list)
588+
- `response["key"]` - Dict-like access to JSON data (for backward compatibility)
589+
590+
For a complete example, see [samples/verbose_mode_example.py](https://github.com/descope/python-sdk/blob/main/samples/verbose_mode_example.py).
591+
539592
### Manage Tenants
540593

541594
You can create, update, delete or load tenants:

descope/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
AuthException,
1818
RateLimitException,
1919
)
20+
from descope.http_client import DescopeResponse
2021
from descope.management.common import (
2122
AssociatedTenant,
2223
SAMLIDPAttributeMappingInfo,

descope/descope_client.py

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

33
import os
4-
from typing import Iterable, Optional
4+
from typing import Iterable
55

66
import requests
77

@@ -27,15 +27,16 @@ class DescopeClient:
2727
def __init__(
2828
self,
2929
project_id: str,
30-
public_key: Optional[dict] = None,
30+
public_key: dict | None = None,
3131
skip_verify: bool = False,
32-
management_key: Optional[str] = None,
32+
management_key: str | None = None,
3333
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
3434
jwt_validation_leeway: int = 5,
35-
auth_management_key: Optional[str] = None,
36-
fga_cache_url: Optional[str] = None,
35+
auth_management_key: str | None = None,
36+
fga_cache_url: str | None = None,
3737
*,
38-
base_url: Optional[str] = None,
38+
base_url: str | None = None,
39+
verbose: bool = False,
3940
):
4041
# validate project id
4142
project_id = project_id or os.getenv("DESCOPE_PROJECT_ID", "")
@@ -57,6 +58,7 @@ def __init__(
5758
secure=not skip_verify,
5859
management_key=auth_management_key
5960
or os.getenv("DESCOPE_AUTH_MANAGEMENT_KEY"),
61+
verbose=verbose,
6062
)
6163
self._auth = Auth(
6264
project_id,
@@ -81,13 +83,18 @@ def __init__(
8183
timeout_seconds=auth_http_client.timeout_seconds,
8284
secure=auth_http_client.secure,
8385
management_key=management_key or os.getenv("DESCOPE_MANAGEMENT_KEY"),
86+
verbose=verbose,
8487
)
8588
self._mgmt = MGMT(
8689
http_client=mgmt_http_client,
8790
auth=self._auth,
8891
fga_cache_url=fga_cache_url,
8992
)
9093

94+
# Store references to HTTP clients for verbose mode access
95+
self._auth_http_client = auth_http_client
96+
self._mgmt_http_client = mgmt_http_client
97+
9198
@property
9299
def mgmt(self):
93100
return self._mgmt
@@ -328,7 +335,7 @@ def get_matched_tenant_roles(
328335
return matched
329336

330337
def validate_session(
331-
self, session_token: str, audience: Optional[Iterable[str] | str] = None
338+
self, session_token: str, audience: Iterable[str] | str | None = None
332339
) -> dict:
333340
"""
334341
Validate a session token. Call this function for every incoming request to your
@@ -351,7 +358,7 @@ def validate_session(
351358
return self._auth.validate_session(session_token, audience)
352359

353360
def refresh_session(
354-
self, refresh_token: str, audience: Optional[Iterable[str] | str] = None
361+
self, refresh_token: str, audience: Iterable[str] | str | None = None
355362
) -> dict:
356363
"""
357364
Refresh a session. Call this function when a session expires and needs to be refreshed.
@@ -372,7 +379,7 @@ def validate_and_refresh_session(
372379
self,
373380
session_token: str,
374381
refresh_token: str,
375-
audience: Optional[Iterable[str] | str] = None,
382+
audience: Iterable[str] | str | None = None,
376383
) -> dict:
377384
"""
378385
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(
472479
self,
473480
refresh_token: str,
474481
dct: bool = False,
475-
ids: Optional[list[str]] = None,
482+
ids: list[str] | None = None,
476483
) -> dict:
477484
"""
478485
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]:
553560
def exchange_access_key(
554561
self,
555562
access_key: str,
556-
audience: Optional[Iterable[str] | str] = None,
557-
login_options: Optional[AccessKeyLoginOptions] = None,
563+
audience: Iterable[str] | str | None = None,
564+
login_options: AccessKeyLoginOptions | None = None,
558565
) -> dict:
559566
"""
560567
Return a new session token for the given access key
@@ -595,3 +602,35 @@ def select_tenant(
595602
AuthException: Exception is raised if session is not authorized or another error occurs
596603
"""
597604
return self._auth.select_tenant(tenant_id, refresh_token)
605+
606+
def get_last_response(self):
607+
"""
608+
Get the last HTTP response from either auth or management operations.
609+
610+
Only available when verbose mode is enabled during client initialization.
611+
This provides access to HTTP metadata like headers (cf-ray), status codes,
612+
and raw response data for debugging failed requests.
613+
614+
Returns:
615+
DescopeResponse: The last response if verbose mode is enabled.
616+
Returns the most recent response from either auth or mgmt operations.
617+
None if verbose mode is disabled or no requests have been made.
618+
619+
Example:
620+
client = DescopeClient(project_id, management_key, verbose=True)
621+
try:
622+
client.mgmt.user.create(login_id="[email protected]")
623+
except AuthException:
624+
resp = client.get_last_response()
625+
if resp:
626+
# Access metadata for debugging
627+
cf_ray = resp.headers.get("cf-ray")
628+
status = resp.status_code
629+
"""
630+
# Return the most recently used response
631+
mgmt_resp = self._mgmt_http_client.get_last_response()
632+
auth_resp = self._auth_http_client.get_last_response()
633+
634+
# Return whichever is not None, preferring mgmt if both exist
635+
# (in practice, only one should be non-None at a time)
636+
return mgmt_resp or auth_resp

0 commit comments

Comments
 (0)