feat: add Python SDK with full StackServerApp spec parity#1291
feat: add Python SDK with full StackServerApp spec parity#1291ejirocodes wants to merge 64 commits intostack-auth:devfrom
Conversation
- Test StackAuthError base class storage and string representation - Test all 13 category subclasses inherit from StackAuthError - Test from_response() dispatch for known and unknown error codes - Test StackAuthModel config and millis conversion - Package skeleton with pyproject.toml (stubs only, tests expected to fail)
- StackAuthError base with code/message/details and from_response() factory - 13 category subclasses: Authentication, NotFound, Validation, PermissionDenied, Conflict, OAuth, Passkey, ApiKey, Payment, Email, RateLimit, Cli, Analytics - Complete _ERROR_CODE_MAP with 143 error codes from known-errors.tsx - StackAuthModel base class with Pydantic v2 ConfigDict and millis conversion - Package exports via __init__.py with __all__
- 22 tests covering construction, headers, URL building, request pipeline - Response processing with x-stack-actual-status and x-stack-known-error - Retry logic with exponential backoff for idempotent methods - 429 rate limit handling with Retry-After header - Context manager protocol for both sync and async clients
- AsyncJWKSFetcher and SyncJWKSFetcher construction and key retrieval - TTL cache behavior with 5-minute expiry - Force-refresh on unknown kid - verify_token for valid, expired, invalid signature, missing kid tokens - CVE-2022-29217 protection: HS256 tokens rejected
…ic models - BaseUser and ServerUser with camelCase aliases and millis-to-datetime properties - Team, ServerTeam, TeamMemberProfile, TeamInvitation with timestamp conversions - ActiveSession with GeoInfo nested model - ContactChannel and TeamPermission/ProjectPermission - Tests for user and team model parsing, inheritance, and field conversion
- AsyncJWKSFetcher and SyncJWKSFetcher with 5-minute in-memory TTL cache - verify_token (async + sync) decodes RS256 JWTs via PyJWT - Algorithm hardcoded to RS256 to prevent CVE-2022-29217 algorithm confusion - Force-refresh on unknown kid for key rotation handling
- BaseAPIClient generic base with header construction, URL building,
response parsing via x-stack-actual-status and x-stack-known-error
- SyncAPIClient wrapping httpx.Client with context manager support
- AsyncAPIClient wrapping httpx.AsyncClient with async context manager
- Exponential backoff retry for idempotent methods (GET/PUT/DELETE)
- 429 rate limit handling with Retry-After header support
- POST/PUT/PATCH with no body sends {} as JSON
- All 26 tests passing
- Project with nested ProjectConfig and OAuthProviderConfig - ApiKey hierarchy: ApiKey, UserApiKey, UserApiKeyFirstView, TeamApiKey, TeamApiKeyFirstView - OAuthConnection and OAuthProvider models - Item and Product payment models - NotificationCategory model - models/__init__.py re-exports all 20+ model classes
- PaginatedResult[T] generic wrapping nested {items, pagination: {next_cursor}} shape
- Convenience properties .next_cursor and .has_next_page
- Package __init__.py exports all models, errors, and PaginatedResult
- SyncAPIClient/AsyncAPIClient intentionally excluded from public API
- Tests verify pagination with ServerUser and Team types
- Tests for decode_access_token_claims (valid JWT, defaults, malformed, missing sub, base64url) - Tests for _extract_token_from_headers (Authorization, lowercase, x-stack-auth, missing) - Tests for sync_authenticate_request and async_authenticate_request
- TokenStore ABC, MemoryTokenStore, ExplicitTokenStore, RequestTokenStore - Registry with shared instances per project_id - CAS refresh algorithm with timing thresholds (20s/75s) - Sync and async variants of get_or_fetch_likely_valid_tokens - Helper functions: _is_fresh_enough, _is_expired, _decode_jwt_payload
- TokenPartialUser frozen dataclass for unverified JWT payload extraction - AuthState frozen dataclass for authentication results - decode_access_token_claims extracts user info without signature verification - _extract_token_from_headers handles Authorization and x-stack-auth headers - sync_authenticate_request and async_authenticate_request compose with JWKS verifier
- Add AuthState, TokenPartialUser, decode_access_token_claims to public API - Add sync_authenticate_request and async_authenticate_request exports - Existing exports unchanged
- TokenStore ABC with get_stored_access_token, get_stored_refresh_token, compare_and_set - MemoryTokenStore, ExplicitTokenStore, RequestTokenStore implementations - Module-level registry shares MemoryTokenStore instances per project_id - resolve_token_store handles memory/dict/request/None init variants - CAS refresh algorithm (sync + async) with 20s expiry buffer and 75s iat threshold - Token refresh via form-encoded POST to /api/v1/auth/oauth/token - Per-instance threading.Lock and asyncio.Lock for concurrency safety - 34 passing tests covering all store types, registry, helpers, and CAS branches
- Tests for get_user, list_users, create_user, update_user, delete_user - Tests for get_user_by_api_key two-step lookup - Tests for sentinel pattern in update_user (None vs not-provided) - Both sync and async facades covered with respx mocks
- StackServerApp (sync) and AsyncStackServerApp (async) facade classes
- get_user returns ServerUser or None on 404
- list_users with cursor/limit/order_by/desc/query/include_restricted/include_anonymous
- create_user sends only non-None fields
- update_user uses _UNSET sentinel to distinguish not-provided from explicit None
- delete_user returns None
- get_user_by_api_key performs two-step lookup via /api-keys/check then /users/{id}
- Both classes support context managers and compose API client + optional token store
- Add top-level imports so users can write: from stack_auth import StackServerApp - Remove outdated comment about Phase 3 adding facades
- list_sessions, get_session, revoke_session tests for sync and async - Tests verify user_id query param, empty results, session filtering
…ServerApp - list_sessions(user_id) returns list of ActiveSession for a user - get_session(session_id, user_id) filters from list_sessions, returns ActiveSession or None - revoke_session(session_id, user_id) deletes session via DELETE endpoint - Both sync and async facades implement all three methods
- Tests for get_team, list_teams, create_team, update_team, delete_team - Tests for get_team_by_api_key two-step lookup - Async equivalents for all team CRUD operations - Uses respx mocking pattern consistent with existing user tests
- Add get_team, list_teams, create_team, update_team, delete_team - Add get_team_by_api_key with two-step lookup via /api-keys/check - Import ServerTeam, TeamInvitation, TeamMemberProfile models - Both sync and async facades have identical method surfaces - Uses _UNSET sentinel pattern for update_team (same as update_user)
- Tests for add_team_member, remove_team_member - Tests for send/list/revoke team invitations - Tests for list_team_member_profiles and get_team_member_profile - Async equivalents for all membership/invitation/profile operations
- Add add_team_member and remove_team_member via /team-memberships - Add send_team_invitation, list_team_invitations, revoke_team_invitation - Add list_team_member_profiles and get_team_member_profile - Add user_id field to TeamMemberProfile for profile filtering - Both sync and async facades have identical method surfaces
- Tests for grant, revoke, list, has, get permission on sync facade - Tests for all permission methods on async facade - Covers team-scoped and project-level permissions
- grant_permission, revoke_permission for team and project scope - list_permissions with optional direct filter - has_permission boolean check, get_permission single lookup - Both sync and async facades
- Tests for list, create, send verification, verify on sync facade - Tests for all contact channel methods on async facade - Covers optional fields and callback URL parameter
- list_contact_channels, create_contact_channel for user channels - send_verification_code with optional callback URL - verify_contact_channel to validate codes - Both sync and async facades
- User API key create/list/revoke on StackServerApp and AsyncStackServerApp - Team API key create/list/revoke on both facades - check_api_key validates any key and returns user_id/team_id - Tests for all sync and async API key methods
- create_oauth_provider links OAuth provider to a user - list_oauth_providers and get_oauth_provider for provider lookup - list_connected_accounts as alias for list_oauth_providers - Sync and async versions on both facade classes with tests
The user_id field was added to TeamMemberProfile during team management implementation but the pre-existing model test was not updated.
|
@greptile-ai review |
…t error handling - PyJWT defaults verify_aud and verify_iss to True, which rejects tokens with iss/aud claims when no expected value is passed. Now explicitly disables these checks when audience/issuer are not provided. - Data vault get/delete now only suppress key-not-found errors (DATA_VAULT_STORE_HASHED_KEY_DOES_NOT_EXIST), letting store-not-found errors (DATA_VAULT_STORE_DOES_NOT_EXIST) propagate as they should.
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (2)
sdks/implementations/python/src/stack_auth/_jwt.py (2)
50-53:⚠️ Potential issue | 🟠 MajorDeduplicate forced JWKS refresh on missing
kidto prevent fetch amplification.Line 52 and Line 103 unconditionally call
force=True; with concurrent misses, each request can trigger another upstream JWKS call. Gate forced refresh once per observed cache snapshot.💡 Proposed fix
class AsyncJWKSFetcher: @@ def __init__(self, jwks_url: str, http_client: httpx.AsyncClient) -> None: @@ self._fetch_lock = asyncio.Lock() + self._refresh_lock = asyncio.Lock() @@ async def get_signing_key(self, kid: str) -> Any: @@ if key_data is None: - # Force-refresh once for potential key rotation - jwks = await self._fetch_jwks(force=True) + observed_cache_time = self._cache_time + async with self._refresh_lock: + if self._cache_time == observed_cache_time: + await self._fetch_jwks(force=True) + jwks = await self._fetch_jwks() key_data = _find_key(jwks, kid) @@ class SyncJWKSFetcher: @@ def __init__(self, jwks_url: str, http_client: httpx.Client) -> None: @@ self._fetch_lock = threading.Lock() + self._refresh_lock = threading.Lock() @@ def get_signing_key(self, kid: str) -> Any: @@ if key_data is None: - jwks = self._fetch_jwks(force=True) + observed_cache_time = self._cache_time + with self._refresh_lock: + if self._cache_time == observed_cache_time: + self._fetch_jwks(force=True) + jwks = self._fetch_jwks() key_data = _find_key(jwks, kid)Also applies to: 102-104
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@sdks/implementations/python/src/stack_auth/_jwt.py` around lines 50 - 53, The code unconditionally calls self._fetch_jwks(force=True) on a missing key, which can cause N concurrent forced refreshes; change the logic in the JWT lookup (the block that checks key_data and uses _find_key and _fetch_jwks) to ensure the forced JWKS refresh runs at most once per observed cache snapshot: read the current cache/version token, if key_data is None atomically check a refreshed-flag or compare the cache token and only call _fetch_jwks(force=True) when this request is the first to observe the stale snapshot, then re-run _find_key against the refreshed jwks; keep references to _find_key, _fetch_jwks, key_data and kid to locate the code.
73-75:⚠️ Potential issue | 🟠 MajorValidate JWKS response shape before caching, and harden key iteration.
Line 73 and Line 124 cache unvalidated JSON, while Line 132 assumes each entry supports
.get. Malformed JWKS can raiseAttributeErrorand bypass the intended unauthenticated fallback path.💡 Proposed fix
async def _fetch_jwks(self, force: bool = False) -> dict[str, Any]: @@ - self._cache = response.json() + data = response.json() + if not isinstance(data, dict) or not isinstance(data.get("keys"), list): + raise ValueError("Invalid JWKS response format") + self._cache = data self._cache_time = time.monotonic() return self._cache # type: ignore[return-value] @@ def _fetch_jwks(self, force: bool = False) -> dict[str, Any]: @@ - self._cache = response.json() + data = response.json() + if not isinstance(data, dict) or not isinstance(data.get("keys"), list): + raise ValueError("Invalid JWKS response format") + self._cache = data self._cache_time = time.monotonic() return self._cache # type: ignore[return-value] @@ def _find_key(jwks: dict[str, Any], kid: str) -> dict[str, Any] | None: """Find a key by kid in a JWKS key set.""" - for key in jwks.get("keys", []): - if key.get("kid") == kid: + keys = jwks.get("keys") + if not isinstance(keys, list): + return None + for key in keys: + if isinstance(key, dict) and key.get("kid") == kid: return key # type: ignore[no-any-return] return NoneAlso applies to: 124-126, 129-133
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@sdks/implementations/python/src/stack_auth/_jwt.py` around lines 73 - 75, The code currently assigns response.json() directly to self._cache and later iterates assuming each entry has .get, which can raise AttributeError on malformed JWKS; instead, validate the JWKS shape before caching: ensure response.json() is a dict with a "keys" key whose value is a list, and that each item in that list is a dict (use isinstance checks). Update the assignment sites that set self._cache (the lines with "self._cache = response.json()") to only set and set self._cache_time when validation passes; if validation fails, do not cache and fall back to the unauthenticated path. Also harden the key iteration code that currently calls item.get(...) to first confirm item is a dict and use item.get safely (or skip non-dict items), and add an explanatory debug/error log when skipping invalid shapes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@sdks/implementations/python/src/stack_auth/models/data_vault.py`:
- Around line 1-109: The spec must be updated to match the Python
implementation: rename the listed method from list() to list_keys(), document
both synchronous (DataVaultStore) and asynchronous (AsyncDataVaultStore)
variants (including getDataVaultStore(id) usage), clarify error semantics so
that a missing key returns null/None while a missing store propagates
NotFoundError, and explicitly state that list_keys() accepts either {"items":
[...]} or a direct list response format. Ensure you reference DataVaultStore,
AsyncDataVaultStore, list_keys(), getDataVaultStore, and NotFoundError in the
spec text so consumers know the exact behavior.
---
Duplicate comments:
In `@sdks/implementations/python/src/stack_auth/_jwt.py`:
- Around line 50-53: The code unconditionally calls self._fetch_jwks(force=True)
on a missing key, which can cause N concurrent forced refreshes; change the
logic in the JWT lookup (the block that checks key_data and uses _find_key and
_fetch_jwks) to ensure the forced JWKS refresh runs at most once per observed
cache snapshot: read the current cache/version token, if key_data is None
atomically check a refreshed-flag or compare the cache token and only call
_fetch_jwks(force=True) when this request is the first to observe the stale
snapshot, then re-run _find_key against the refreshed jwks; keep references to
_find_key, _fetch_jwks, key_data and kid to locate the code.
- Around line 73-75: The code currently assigns response.json() directly to
self._cache and later iterates assuming each entry has .get, which can raise
AttributeError on malformed JWKS; instead, validate the JWKS shape before
caching: ensure response.json() is a dict with a "keys" key whose value is a
list, and that each item in that list is a dict (use isinstance checks). Update
the assignment sites that set self._cache (the lines with "self._cache =
response.json()") to only set and set self._cache_time when validation passes;
if validation fails, do not cache and fall back to the unauthenticated path.
Also harden the key iteration code that currently calls item.get(...) to first
confirm item is a dict and use item.get safely (or skip non-dict items), and add
an explanatory debug/error log when skipping invalid shapes.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 37386028-c8be-4f39-8c7e-89001b6f088b
📒 Files selected for processing (2)
sdks/implementations/python/src/stack_auth/_jwt.pysdks/implementations/python/src/stack_auth/models/data_vault.py
Both sync and async get_item() were missing the None check that other getters (get_user, get_team) already have. An empty response body would surface as a pydantic ValidationError instead of a meaningful NotFoundError.
A 429 guarantees the server did not process the request, so retrying is safe regardless of idempotency. This differs from network error retries which are restricted to idempotent methods. Added inline comments to both sync and async client request loops.
|
@coderabbitai resume |
✅ Actions performedReviews resumed. |
|
@greptile-ai review |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@sdks/implementations/python/src/stack_auth/_client.py`:
- Around line 80-81: The code uses int(actual_status_header) on the
"x-stack-actual-status" header (variables actual_status_header and
actual_status) without guarding against malformed values; wrap the conversion in
a try/except (catch ValueError and TypeError) and on failure set actual_status
to response.status_code (and optionally log the parse failure) so no exception
escapes and the normal retry and StackAuthError normalization paths are
preserved; apply the same guarded conversion to the other two occurrences that
use the same pattern.
- Around line 84-94: The current known-error handling assumes response.json()
returns a dict and calls body.get(...), which will raise AttributeError for
non-object JSON (null, list, string); update the block that reads known_error
and parses response.json() (the variables known_error, response.json(), and the
call to StackAuthError.from_response) to defensively coerce or validate the
parsed body into a dict (e.g., if parsed body is not a mapping, replace it with
an empty dict or wrap it so .get works) before calling body.get("message", ...)
and body.get("details"), so StackAuthError.from_response is always called with
safe message/details values.
- Around line 113-131: The _get_retry_delay helper currently only accepts
numeric Retry-After values; update it to also accept HTTP-date formats by trying
to parse Retry-After with email.utils.parsedate_to_datetime when float() raises
ValueError, computing seconds = (parsed_datetime -
datetime.now(timezone.utc)).total_seconds(), and returning max(0.0, seconds) if
positive (otherwise 0.0); apply this logic for both places where Retry-After is
read in _get_retry_delay (and consider extracting a small helper like
_parse_retry_after to reuse the parsing logic).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: a5d759ef-e402-4873-adf2-b78f340a9b7d
📒 Files selected for processing (2)
sdks/implementations/python/src/stack_auth/_app.pysdks/implementations/python/src/stack_auth/_client.py
✅ Files skipped from review due to trivial changes (1)
- sdks/implementations/python/src/stack_auth/_app.py
- Guard int() on x-stack-actual-status header against malformed values in _parse_response and both retry loops (ValueError falls back to response.status_code) - Validate response.json() returns dict before calling .get() on known-error body (non-dict JSON falls back to empty dict) - Add None guard to get_email_delivery_stats before model_validate (matches pattern used by get_user, get_team, get_item)
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (1)
sdks/implementations/python/src/stack_auth/_client.py (1)
120-138:⚠️ Potential issue | 🟠 MajorSupport HTTP-date
Retry-Aftervalues.This helper still only accepts numeric seconds. A valid HTTP-date header falls back to exponential backoff here, so 429 retries can run earlier than the server requested.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@sdks/implementations/python/src/stack_auth/_client.py` around lines 120 - 138, In _get_retry_delay, support HTTP-date Retry-After values in addition to numeric seconds: when reading response.headers.get("Retry-After") inside _get_retry_delay (and in the x-stack-actual-status 429 branch) try parsing the header first as a float and return that; if float parsing fails, parse it as an HTTP-date (e.g., using a timezone-aware parser like email.utils.parsedate_to_datetime or equivalent), compute max(0, (parsed_date - now).total_seconds()), and return that; ensure exceptions fall through to the exponential backoff fallback and that the computed delay is non-negative.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@sdks/implementations/python/src/stack_auth/_app.py`:
- Around line 1263-1301: In grant_product, add a local guard to validate product
inputs so callers cannot pass both product_id and product or omit both: check
the two variables (product_id, product) and if both set or both falsy raise
ValueError with a clear message; keep the existing customer resolution
(_resolve_customer_path), param building (_build_params), and request call but
only after this validation. Apply the same mutually-exclusive validation to the
other grant-like method(s) that accept product_id/product to prevent forwarding
ambiguous inputs to self._client.request.
---
Duplicate comments:
In `@sdks/implementations/python/src/stack_auth/_client.py`:
- Around line 120-138: In _get_retry_delay, support HTTP-date Retry-After values
in addition to numeric seconds: when reading response.headers.get("Retry-After")
inside _get_retry_delay (and in the x-stack-actual-status 429 branch) try
parsing the header first as a float and return that; if float parsing fails,
parse it as an HTTP-date (e.g., using a timezone-aware parser like
email.utils.parsedate_to_datetime or equivalent), compute max(0, (parsed_date -
now).total_seconds()), and return that; ensure exceptions fall through to the
exponential backoff fallback and that the computed delay is non-negative.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: f40d8f51-2969-4cfb-9076-4b1603563fb5
📒 Files selected for processing (2)
sdks/implementations/python/src/stack_auth/_app.pysdks/implementations/python/src/stack_auth/_client.py
The spec requires exactly one of product_id or product (inline definition). Added validation to both sync and async grant_product methods to raise ValueError if both or neither are provided, matching the existing _resolve_customer_path pattern for customer identifiers.
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (1)
sdks/implementations/python/src/stack_auth/_client.py (1)
260-264: Minor: Potential race in lazy client initialization.In async contexts, multiple coroutines calling
_get_client()simultaneously could both see_client is Noneand create separateAsyncClientinstances. The impact is minor (just wasted resources; functionality is correct), but if you want to guard against this:Optional fix using asyncio.Lock
class AsyncAPIClient(BaseAPIClient[httpx.AsyncClient]): """Asynchronous HTTP client using :class:`httpx.AsyncClient`.""" + + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + self._client_lock = asyncio.Lock() - def _get_client(self) -> httpx.AsyncClient: + async def _get_client(self) -> httpx.AsyncClient: """Return the underlying httpx.AsyncClient, creating it lazily if needed.""" - if self._client is None: - self._client = httpx.AsyncClient(timeout=httpx.Timeout(30.0)) + if self._client is None: + async with self._client_lock: + if self._client is None: + self._client = httpx.AsyncClient(timeout=httpx.Timeout(30.0)) return self._client🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@sdks/implementations/python/src/stack_auth/_client.py` around lines 260 - 264, The lazy initialization in _get_client can race in async code; make it safe by adding an asyncio.Lock instance (e.g. self._client_lock = asyncio.Lock()) in the class __init__, change _get_client to an async def that acquires the lock (await self._client_lock) and uses a double-checked pattern to create self._client only if still None, then release the lock and return the client; update callers to await _get_client() or alternatively use an async helper get_client that wraps this behavior so only one AsyncClient is ever created concurrently.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@sdks/implementations/python/src/stack_auth/_token_store.py`:
- Around line 391-394: On HTTP 200 paths where you call resp.json() and then
data.get("access_token"), guard against non-dict JSON responses: after data =
resp.json() verify isinstance(data, dict) (or has attribute get) before calling
data.get("access_token"); if not a dict treat as a failure and return (False,
None) (same change should be applied to the other refresh-response handling
block that uses resp.json() and data.get("access_token") around the lines
referenced). Ensure you update both occurrences so non-object JSON doesn't raise
AttributeError and instead falls back to the failure return.
- Around line 251-294: The JWT handlers assume exp/iat are numeric and may raise
TypeError on malformed claims; update _is_expired and _is_fresh_enough to
validate and normalize claim types before doing comparisons: in _is_expired call
_decode_jwt_payload(token), ensure claims is a dict, get exp and if not
isinstance(exp, (int, float)) attempt to coerce via float(...) in a try/except
and treat failures as token invalid/expired (return True); in _is_fresh_enough
similarly validate/coerce both exp and iat and on any missing or non-numeric
values return False; ensure any arithmetic is wrapped to avoid uncaught
TypeError/ValueError and preserve current boolean semantics for invalid tokens.
In `@sdks/implementations/python/src/stack_auth/errors.py`:
- Line 202: The SDK maps the error code TEAM_MEMBERSHIP_NOT_FOUND to the wrong
exception type; change its mapping from ConflictError to NotFoundError in the
error mapping (the entry `"TEAM_MEMBERSHIP_NOT_FOUND": ConflictError` in
errors.py), ensure NotFoundError is imported/available in that module, and run
the test suite to confirm behavior matches other *_NOT_FOUND codes (e.g.,
TEAM_NOT_FOUND, USER_NOT_FOUND, API_KEY_NOT_FOUND).
---
Nitpick comments:
In `@sdks/implementations/python/src/stack_auth/_client.py`:
- Around line 260-264: The lazy initialization in _get_client can race in async
code; make it safe by adding an asyncio.Lock instance (e.g. self._client_lock =
asyncio.Lock()) in the class __init__, change _get_client to an async def that
acquires the lock (await self._client_lock) and uses a double-checked pattern to
create self._client only if still None, then release the lock and return the
client; update callers to await _get_client() or alternatively use an async
helper get_client that wraps this behavior so only one AsyncClient is ever
created concurrently.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 4f7a6179-b86a-40c4-824c-3569906e44d1
📒 Files selected for processing (8)
sdks/implementations/python/src/stack_auth/_app.pysdks/implementations/python/src/stack_auth/_client.pysdks/implementations/python/src/stack_auth/_jwt.pysdks/implementations/python/src/stack_auth/_token_store.pysdks/implementations/python/src/stack_auth/_types.pysdks/implementations/python/src/stack_auth/errors.pysdks/implementations/python/src/stack_auth/models/data_vault.pysdks/implementations/python/src/stack_auth/models/payments.py
✅ Files skipped from review due to trivial changes (2)
- sdks/implementations/python/src/stack_auth/models/payments.py
- sdks/implementations/python/src/stack_auth/_app.py
🚧 Files skipped from review as they are similar to previous changes (1)
- sdks/implementations/python/src/stack_auth/models/data_vault.py
resp.json() could return non-dict values (null, string, list) which
would cause AttributeError on data.get("access_token"). Now checks
isinstance(data, dict) first, returning (False, None) for malformed
responses. Applied to both sync and async refresh helpers.
Backend returns HTTP 404 for this error code. Was incorrectly mapped to ConflictError. Now matches other *_NOT_FOUND codes like TEAM_NOT_FOUND, USER_NOT_FOUND, and API_KEY_NOT_FOUND.
|
@greptile-ai review |
There was a problem hiding this comment.
🧹 Nitpick comments (2)
sdks/implementations/python/src/stack_auth/_token_store.py (2)
19-19: Optional: Import fromcollections.abcfor modern Python.Since Python 3.10+ is required,
AwaitableandCallableshould be imported fromcollections.abcrather thantypingper PEP 585.♻️ Suggested change
-from typing import Any, Awaitable, Callable, Literal, Union +from collections.abc import Awaitable, Callable +from typing import Any, Literal, Union🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@sdks/implementations/python/src/stack_auth/_token_store.py` at line 19, Update the imports to use the modern collections.abc locations for runtime/generic types: replace importing Awaitable and Callable from typing with importing them from collections.abc while keeping Any, Literal, Union from typing; modify the top-level import line that currently mentions Awaitable and Callable so it imports Awaitable and Callable from collections.abc and the other types remain from typing to satisfy PEP 585 and Python 3.10+ expectations (refer to symbols Awaitable, Callable in _token_store.py).
222-222: Optional: UseX | Yunion syntax for type annotations.With Python 3.10+ as the minimum version, the modern union syntax is preferred.
♻️ Suggested change
-TokenStoreInit = Union[Literal["memory"], dict, RequestLike, None] +TokenStoreInit = Literal["memory"] | dict | RequestLike | None🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@sdks/implementations/python/src/stack_auth/_token_store.py` at line 222, The type alias TokenStoreInit currently uses typing.Union; update it to the modern Python 3.10+ union syntax by replacing "TokenStoreInit = Union[Literal[\"memory\"], dict, RequestLike, None]" with "TokenStoreInit = Literal[\"memory\"] | dict | RequestLike | None", and remove the now-unused Union import if it isn't referenced elsewhere in the module; keep Literal and RequestLike imports as needed.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@sdks/implementations/python/src/stack_auth/_token_store.py`:
- Line 19: Update the imports to use the modern collections.abc locations for
runtime/generic types: replace importing Awaitable and Callable from typing with
importing them from collections.abc while keeping Any, Literal, Union from
typing; modify the top-level import line that currently mentions Awaitable and
Callable so it imports Awaitable and Callable from collections.abc and the other
types remain from typing to satisfy PEP 585 and Python 3.10+ expectations (refer
to symbols Awaitable, Callable in _token_store.py).
- Line 222: The type alias TokenStoreInit currently uses typing.Union; update it
to the modern Python 3.10+ union syntax by replacing "TokenStoreInit =
Union[Literal[\"memory\"], dict, RequestLike, None]" with "TokenStoreInit =
Literal[\"memory\"] | dict | RequestLike | None", and remove the now-unused
Union import if it isn't referenced elsewhere in the module; keep Literal and
RequestLike imports as needed.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 33f8083c-3bc9-418b-a5a5-7bf0d210b583
📒 Files selected for processing (2)
sdks/implementations/python/src/stack_auth/_token_store.pysdks/implementations/python/src/stack_auth/errors.py
🚧 Files skipped from review as they are similar to previous changes (1)
- sdks/implementations/python/src/stack_auth/errors.py
Move Awaitable and Callable imports to collections.abc per PEP 585. Replace Union type alias with pipe syntax for TokenStoreInit.
RateLimitError was defined and exported but could never be raised. When all retry attempts are exhausted on a 429 response, _parse_response now raises RateLimitError instead of a generic StackAuthError. Callers writing `except RateLimitError` will now correctly catch rate limit failures.
|
@greptile-ai review |
There was a problem hiding this comment.
🧹 Nitpick comments (2)
sdks/implementations/python/src/stack_auth/_token_store.py (2)
392-397: Returning(True, None)whenaccess_tokenis missing could be misleading.If the server returns HTTP 200 but the response lacks an
access_tokenfield (or it'sNone), the function returns(True, None). While the caller handles this correctly by checkingwas_valid and new_access is not None, theTruesuggests the refresh was "valid" when it arguably wasn't.Consider treating a missing
access_tokenas a failure:♻️ Suggested refinement
if resp.status_code == 200: data = resp.json() if not isinstance(data, dict): return (False, None) - return (True, data.get("access_token")) + access_token = data.get("access_token") + if not isinstance(access_token, str): + return (False, None) + return (True, access_token) return (False, None)Also applies to lines 425-430 (async variant).
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@sdks/implementations/python/src/stack_auth/_token_store.py` around lines 392 - 397, The code currently returns (True, None) when resp.status_code == 200 but data.get("access_token") is missing; change the logic so that a missing or None access_token is treated as a failure and the function returns (False, None) instead of (True, None). Update the synchronous branch that inspects resp.status_code/resp.json() and the analogous asynchronous branch (the async variant handling resp.status_code and data.get("access_token")) to check that access_token is present before returning True, and return False when it's absent.
19-20: Consider moving type-only imports toTYPE_CHECKINGblock.Since
from __future__ import annotationsis active,AwaitableandCallableare only needed for static type checking and can be moved to aTYPE_CHECKINGblock to avoid unnecessary runtime imports.♻️ Suggested refactor
+from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable -from collections.abc import Awaitable, Callable from typing import Any, Literal🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@sdks/implementations/python/src/stack_auth/_token_store.py` around lines 19 - 20, Move the type-only imports into a TYPE_CHECKING guard: add "from typing import TYPE_CHECKING" to the top and wrap the Awaitable and Callable imports in an "if TYPE_CHECKING:" block so they are only imported for static type checking (leave Any and Literal as-is if they are used at runtime). This targets the current import lines referencing Awaitable and Callable (and works with the existing "from __future__ import annotations") to avoid unnecessary runtime imports.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@sdks/implementations/python/src/stack_auth/_token_store.py`:
- Around line 392-397: The code currently returns (True, None) when
resp.status_code == 200 but data.get("access_token") is missing; change the
logic so that a missing or None access_token is treated as a failure and the
function returns (False, None) instead of (True, None). Update the synchronous
branch that inspects resp.status_code/resp.json() and the analogous asynchronous
branch (the async variant handling resp.status_code and
data.get("access_token")) to check that access_token is present before returning
True, and return False when it's absent.
- Around line 19-20: Move the type-only imports into a TYPE_CHECKING guard: add
"from typing import TYPE_CHECKING" to the top and wrap the Awaitable and
Callable imports in an "if TYPE_CHECKING:" block so they are only imported for
static type checking (leave Any and Literal as-is if they are used at runtime).
This targets the current import lines referencing Awaitable and Callable (and
works with the existing "from __future__ import annotations") to avoid
unnecessary runtime imports.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 3fa26ee8-327c-4eb8-b196-1d7a405abb2d
📒 Files selected for processing (1)
sdks/implementations/python/src/stack_auth/_token_store.py
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
Return (False, None) instead of (True, None) when the OAuth token endpoint returns 200 but the response has no access_token field. Also moved Awaitable/Callable imports behind TYPE_CHECKING guard since they are only used for type annotations.
There was a problem hiding this comment.
🧹 Nitpick comments (2)
sdks/implementations/python/src/stack_auth/_token_store.py (1)
19-20: Consider moving type-only imports into aTYPE_CHECKINGblock.Static analysis (Ruff TC003) flags
AwaitableandCallableas imports used only for type annotations. Moving them into aTYPE_CHECKINGblock avoids importing them at runtime.♻️ Suggested refactor
+from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable -from collections.abc import Awaitable, Callable from typing import Any, LiteralThen update the type annotations on lines 303 and 338 to use string quotes if needed (though Python 3.10+ with
from __future__ import annotationsalready defers evaluation, so this may work as-is).🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@sdks/implementations/python/src/stack_auth/_token_store.py` around lines 19 - 20, Move the runtime imports of Awaitable and Callable into a TYPE_CHECKING block to avoid importing them at runtime: import TYPE_CHECKING from typing, wrap "from collections.abc import Awaitable, Callable" inside "if TYPE_CHECKING: ...". Then update the annotations in this module that reference Awaitable and Callable to either remain as normal (if using PEP 563 / from __future__ import annotations) or convert them to forward-ref string literals so they are only used for type checking; ensure references to Awaitable and Callable in the module's type annotations (the places using those types) are adjusted accordingly.sdks/implementations/python/src/stack_auth/_client.py (1)
90-97: Extractx-stack-actual-statusparsing into a shared helper to prevent drift.The same parsing/fallback logic appears in
_parse_response,SyncAPIClient.request, andAsyncAPIClient.request. Centralizing it in one helper will reduce maintenance risk and keep sync/async behavior locked together.♻️ Proposed refactor
class BaseAPIClient(Generic[HttpxClientT]): @@ + `@staticmethod` + def _get_actual_status(response: httpx.Response) -> int: + actual_status_header = response.headers.get("x-stack-actual-status") + if actual_status_header: + try: + return int(actual_status_header) + except ValueError: + return response.status_code + return response.status_code + def _parse_response(self, response: httpx.Response) -> tuple[int, dict[str, Any] | None]: @@ - actual_status_header = response.headers.get("x-stack-actual-status") - if actual_status_header: - try: - actual_status = int(actual_status_header) - except ValueError: - actual_status = response.status_code - else: - actual_status = response.status_code + actual_status = self._get_actual_status(response) @@ - actual_status_hdr = resp.headers.get("x-stack-actual-status") - try: - actual_status = int(actual_status_hdr) if actual_status_hdr else resp.status_code - except ValueError: - actual_status = resp.status_code + actual_status = self._get_actual_status(resp) @@ - actual_status_hdr = resp.headers.get("x-stack-actual-status") - try: - actual_status = int(actual_status_hdr) if actual_status_hdr else resp.status_code - except ValueError: - actual_status = resp.status_code + actual_status = self._get_actual_status(resp)Also applies to: 210-215, 312-317
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@sdks/implementations/python/src/stack_auth/_client.py` around lines 90 - 97, Extract the x-stack-actual-status parsing/fallback logic into a single helper (e.g., _parse_actual_status(headers) or parse_actual_status) and replace the duplicated blocks in _parse_response, SyncAPIClient.request, and AsyncAPIClient.request with calls to that helper; the helper should accept response.headers (or headers mapping), try to read and int() the "x-stack-actual-status" header, and on ValueError or missing header return response.status_code (or accept a fallback status argument) so both sync and async code paths use the same logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@sdks/implementations/python/src/stack_auth/_client.py`:
- Around line 90-97: Extract the x-stack-actual-status parsing/fallback logic
into a single helper (e.g., _parse_actual_status(headers) or
parse_actual_status) and replace the duplicated blocks in _parse_response,
SyncAPIClient.request, and AsyncAPIClient.request with calls to that helper; the
helper should accept response.headers (or headers mapping), try to read and
int() the "x-stack-actual-status" header, and on ValueError or missing header
return response.status_code (or accept a fallback status argument) so both sync
and async code paths use the same logic.
In `@sdks/implementations/python/src/stack_auth/_token_store.py`:
- Around line 19-20: Move the runtime imports of Awaitable and Callable into a
TYPE_CHECKING block to avoid importing them at runtime: import TYPE_CHECKING
from typing, wrap "from collections.abc import Awaitable, Callable" inside "if
TYPE_CHECKING: ...". Then update the annotations in this module that reference
Awaitable and Callable to either remain as normal (if using PEP 563 / from
__future__ import annotations) or convert them to forward-ref string literals so
they are only used for type checking; ensure references to Awaitable and
Callable in the module's type annotations (the places using those types) are
adjusted accordingly.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 5f747aea-6e13-45a2-aa39-628475e46713
📒 Files selected for processing (2)
sdks/implementations/python/src/stack_auth/_client.pysdks/implementations/python/src/stack_auth/_token_store.py
…sing The x-stack-actual-status header was parsed with identical try/except blocks in 4 places. Extracted into a single _get_actual_status() static method on BaseAPIClient, used by _parse_response, _get_retry_delay, and both sync/async request() loops.
Summary
Adds a Python SDK at
sdks/implementations/python/following the SDK spec atsdks/spec/. Covers the fullStackServerApp surface with sync and async support.
Built using the SDK spec and Swift SDK as reference implementations.
What's included
StackServerApp(sync) andAsyncStackServerApp(async) via genericBaseAPIClient[T]authenticate_request()for verifying incoming tokensStack
Design decisions
aget_*methods (follows Anthropic SDK pattern).codeattributeauthenticate_request()works with any object that has a.headersmapping
Not included (future work)
Test plan
pip install -e ".[dev]"installs cleanlypython3 -m pytest tests/ -vpasses (358 tests)STACK_E2E=1 python3 -m pytest tests/test_e2e.py -v -spasses against running Stack Authpython3 -m buildproduces clean wheelfrom stack_auth import StackServerAppworks from installed packageSummary by CodeRabbit