test: add failing tests for sync and async HTTP client

- 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
This commit is contained in:
Ejiro Asiuwhu 2026-03-24 22:22:32 +01:00
parent a8c18f366b
commit fbe1c5bfc3

View File

@ -0,0 +1,322 @@
"""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