mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
- 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
323 lines
12 KiB
Python
323 lines
12 KiB
Python
"""Tests for the sync and async HTTP client classes."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import re
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import httpx
|
|
import pytest
|
|
import respx
|
|
|
|
from stack_auth._client import AsyncAPIClient, BaseAPIClient, SyncAPIClient
|
|
from stack_auth._version import __version__
|
|
from stack_auth.errors import AuthenticationError, NotFoundError, StackAuthError
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Construction tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestClientConstruction:
|
|
def test_sync_client_constructs(self) -> None:
|
|
client = SyncAPIClient(project_id="proj", secret_server_key="sk")
|
|
assert client is not None
|
|
assert isinstance(client, SyncAPIClient)
|
|
|
|
def test_async_client_constructs(self) -> None:
|
|
client = AsyncAPIClient(project_id="proj", secret_server_key="sk")
|
|
assert client is not None
|
|
assert isinstance(client, AsyncAPIClient)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Header tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestBuildHeaders:
|
|
def setup_method(self) -> None:
|
|
self.client = SyncAPIClient(project_id="proj_123", secret_server_key="sk_secret")
|
|
|
|
def test_project_id_header(self) -> None:
|
|
headers = self.client._build_headers()
|
|
assert headers["x-stack-project-id"] == "proj_123"
|
|
|
|
def test_access_type_header(self) -> None:
|
|
headers = self.client._build_headers()
|
|
assert headers["x-stack-access-type"] == "server"
|
|
|
|
def test_secret_server_key_header(self) -> None:
|
|
headers = self.client._build_headers()
|
|
assert headers["x-stack-secret-server-key"] == "sk_secret"
|
|
|
|
def test_client_version_header(self) -> None:
|
|
headers = self.client._build_headers()
|
|
assert headers["x-stack-client-version"] == f"python@{__version__}"
|
|
|
|
def test_override_error_status_header(self) -> None:
|
|
headers = self.client._build_headers()
|
|
assert headers["x-stack-override-error-status"] == "true"
|
|
|
|
def test_random_nonce_header_is_uuid(self) -> None:
|
|
headers = self.client._build_headers()
|
|
nonce = headers["x-stack-random-nonce"]
|
|
# UUID v4 pattern
|
|
uuid_pattern = re.compile(
|
|
r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
|
|
re.IGNORECASE,
|
|
)
|
|
assert uuid_pattern.match(nonce), f"Nonce {nonce!r} is not a valid UUID v4"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# URL building tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestBuildUrl:
|
|
def test_default_base_url(self) -> None:
|
|
client = SyncAPIClient(project_id="p", secret_server_key="s")
|
|
assert client._build_url("/users") == "https://api.stack-auth.com/api/v1/users"
|
|
|
|
def test_custom_base_url(self) -> None:
|
|
client = SyncAPIClient(project_id="p", secret_server_key="s", base_url="https://custom.host")
|
|
assert client._build_url("/users") == "https://custom.host/api/v1/users"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Sync request tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSyncRequest:
|
|
@respx.mock
|
|
def test_get_returns_json(self) -> None:
|
|
route = respx.get("https://api.stack-auth.com/api/v1/users").mock(
|
|
return_value=httpx.Response(
|
|
200,
|
|
json={"id": "user_1"},
|
|
headers={"x-stack-actual-status": "200"},
|
|
)
|
|
)
|
|
client = SyncAPIClient(project_id="p", secret_server_key="s")
|
|
result = client.request("GET", "/users")
|
|
assert result == {"id": "user_1"}
|
|
|
|
@respx.mock
|
|
def test_known_error_raises_not_found(self) -> None:
|
|
respx.get("https://api.stack-auth.com/api/v1/users/123").mock(
|
|
return_value=httpx.Response(
|
|
200,
|
|
json={"code": "USER_NOT_FOUND", "message": "User not found"},
|
|
headers={
|
|
"x-stack-actual-status": "400",
|
|
"x-stack-known-error": "USER_NOT_FOUND",
|
|
},
|
|
)
|
|
)
|
|
client = SyncAPIClient(project_id="p", secret_server_key="s")
|
|
with pytest.raises(NotFoundError) as exc_info:
|
|
client.request("GET", "/users/123")
|
|
assert exc_info.value.code == "USER_NOT_FOUND"
|
|
|
|
@respx.mock
|
|
def test_known_error_raises_authentication_error(self) -> None:
|
|
respx.get("https://api.stack-auth.com/api/v1/me").mock(
|
|
return_value=httpx.Response(
|
|
200,
|
|
json={"code": "INVALID_ACCESS_TOKEN", "message": "Bad token"},
|
|
headers={
|
|
"x-stack-actual-status": "400",
|
|
"x-stack-known-error": "INVALID_ACCESS_TOKEN",
|
|
},
|
|
)
|
|
)
|
|
client = SyncAPIClient(project_id="p", secret_server_key="s")
|
|
with pytest.raises(AuthenticationError):
|
|
client.request("GET", "/me")
|
|
|
|
@respx.mock
|
|
def test_http_error_on_unknown_status(self) -> None:
|
|
respx.get("https://api.stack-auth.com/api/v1/fail").mock(
|
|
return_value=httpx.Response(
|
|
200,
|
|
json={},
|
|
headers={"x-stack-actual-status": "500"},
|
|
)
|
|
)
|
|
client = SyncAPIClient(project_id="p", secret_server_key="s")
|
|
with pytest.raises(StackAuthError) as exc_info:
|
|
client.request("GET", "/fail")
|
|
assert exc_info.value.code == "HTTP_ERROR"
|
|
|
|
@respx.mock
|
|
def test_post_with_none_body_sends_empty_json(self) -> None:
|
|
route = respx.post("https://api.stack-auth.com/api/v1/items").mock(
|
|
return_value=httpx.Response(
|
|
200,
|
|
json={"ok": True},
|
|
headers={"x-stack-actual-status": "200"},
|
|
)
|
|
)
|
|
client = SyncAPIClient(project_id="p", secret_server_key="s")
|
|
client.request("POST", "/items", body=None)
|
|
sent_request = route.calls[0].request
|
|
assert sent_request.content == b"{}"
|
|
|
|
@respx.mock
|
|
def test_get_with_none_body_sends_no_body(self) -> None:
|
|
route = respx.get("https://api.stack-auth.com/api/v1/items").mock(
|
|
return_value=httpx.Response(
|
|
200,
|
|
json={"ok": True},
|
|
headers={"x-stack-actual-status": "200"},
|
|
)
|
|
)
|
|
client = SyncAPIClient(project_id="p", secret_server_key="s")
|
|
client.request("GET", "/items", body=None)
|
|
sent_request = route.calls[0].request
|
|
assert sent_request.content == b""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Async request tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestAsyncRequest:
|
|
@respx.mock
|
|
@pytest.mark.asyncio
|
|
async def test_get_returns_json(self) -> None:
|
|
respx.get("https://api.stack-auth.com/api/v1/users").mock(
|
|
return_value=httpx.Response(
|
|
200,
|
|
json={"id": "user_1"},
|
|
headers={"x-stack-actual-status": "200"},
|
|
)
|
|
)
|
|
client = AsyncAPIClient(project_id="p", secret_server_key="s")
|
|
result = await client.request("GET", "/users")
|
|
assert result == {"id": "user_1"}
|
|
await client.aclose()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Retry tests (sync)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestRetryLogic:
|
|
@respx.mock
|
|
@patch("time.sleep")
|
|
def test_get_retries_on_connect_error(self, mock_sleep: AsyncMock) -> None:
|
|
url = "https://api.stack-auth.com/api/v1/data"
|
|
route = respx.get(url).mock(side_effect=httpx.ConnectError("Connection refused"))
|
|
client = SyncAPIClient(project_id="p", secret_server_key="s")
|
|
with pytest.raises(httpx.ConnectError):
|
|
client.request("GET", "/data")
|
|
# 1 initial + 5 retries = 6 total attempts
|
|
assert route.call_count == 6
|
|
|
|
@respx.mock
|
|
@patch("time.sleep")
|
|
def test_post_does_not_retry_on_connect_error(self, mock_sleep: AsyncMock) -> None:
|
|
url = "https://api.stack-auth.com/api/v1/data"
|
|
route = respx.post(url).mock(side_effect=httpx.ConnectError("Connection refused"))
|
|
client = SyncAPIClient(project_id="p", secret_server_key="s")
|
|
with pytest.raises(httpx.ConnectError):
|
|
client.request("POST", "/data")
|
|
assert route.call_count == 1
|
|
|
|
@respx.mock
|
|
@patch("time.sleep")
|
|
def test_retry_exponential_backoff_delays(self, mock_sleep: AsyncMock) -> None:
|
|
url = "https://api.stack-auth.com/api/v1/data"
|
|
respx.get(url).mock(side_effect=httpx.ConnectError("fail"))
|
|
client = SyncAPIClient(project_id="p", secret_server_key="s")
|
|
with pytest.raises(httpx.ConnectError):
|
|
client.request("GET", "/data")
|
|
delays = [call.args[0] for call in mock_sleep.call_args_list]
|
|
assert delays == [1.0, 2.0, 4.0, 8.0, 16.0]
|
|
|
|
@respx.mock
|
|
@patch("time.sleep")
|
|
def test_429_retries_with_retry_after(self, mock_sleep: AsyncMock) -> None:
|
|
url = "https://api.stack-auth.com/api/v1/data"
|
|
# First call: 429 with Retry-After, second call: success
|
|
respx.get(url).mock(
|
|
side_effect=[
|
|
httpx.Response(
|
|
200,
|
|
json={},
|
|
headers={"x-stack-actual-status": "429", "Retry-After": "3"},
|
|
),
|
|
httpx.Response(
|
|
200,
|
|
json={"ok": True},
|
|
headers={"x-stack-actual-status": "200"},
|
|
),
|
|
]
|
|
)
|
|
client = SyncAPIClient(project_id="p", secret_server_key="s")
|
|
result = client.request("GET", "/data")
|
|
assert result == {"ok": True}
|
|
mock_sleep.assert_called_once_with(3.0)
|
|
|
|
@respx.mock
|
|
@patch("time.sleep")
|
|
def test_429_without_retry_after_uses_backoff(self, mock_sleep: AsyncMock) -> None:
|
|
url = "https://api.stack-auth.com/api/v1/data"
|
|
respx.get(url).mock(
|
|
side_effect=[
|
|
httpx.Response(
|
|
200,
|
|
json={},
|
|
headers={"x-stack-actual-status": "429"},
|
|
),
|
|
httpx.Response(
|
|
200,
|
|
json={"ok": True},
|
|
headers={"x-stack-actual-status": "200"},
|
|
),
|
|
]
|
|
)
|
|
client = SyncAPIClient(project_id="p", secret_server_key="s")
|
|
result = client.request("GET", "/data")
|
|
assert result == {"ok": True}
|
|
# First attempt is attempt=0, so backoff = 1.0 * (2 ** 0) = 1.0
|
|
mock_sleep.assert_called_once_with(1.0)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Context manager tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestContextManager:
|
|
def test_sync_context_manager(self) -> None:
|
|
with SyncAPIClient(project_id="p", secret_server_key="s") as client:
|
|
assert isinstance(client, SyncAPIClient)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_context_manager(self) -> None:
|
|
async with AsyncAPIClient(project_id="p", secret_server_key="s") as client:
|
|
assert isinstance(client, AsyncAPIClient)
|
|
|
|
def test_sync_close(self) -> None:
|
|
client = SyncAPIClient(project_id="p", secret_server_key="s")
|
|
# Force client creation by accessing it
|
|
_ = client._get_client()
|
|
client.close()
|
|
# After close, internal client should be None
|
|
assert client._client is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_aclose(self) -> None:
|
|
client = AsyncAPIClient(project_id="p", secret_server_key="s")
|
|
_ = client._get_client()
|
|
await client.aclose()
|
|
assert client._client is None
|