Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 155 additions & 0 deletions src/archastro/platform/runtime/http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from __future__ import annotations

import asyncio
import threading
from collections.abc import Callable, Coroutine
from typing import Any

Expand Down Expand Up @@ -199,6 +200,160 @@ async def close(self) -> None:
await self._client.aclose()


class SyncHttpClient:
def __init__(
self,
*,
base_url: str,
access_token: str | None = None,
get_access_token: Callable[[], str | None] | None = None,
on_refresh_token: Callable[[], str] | None = None,
path_prefix: str | None = None,
default_headers: dict[str, str] | None = None,
refresh_only: bool = False,
):
self._base_url = base_url.rstrip("/")
self._access_token = access_token
self._get_access_token = get_access_token
self._on_refresh_token = on_refresh_token
self._path_prefix = path_prefix
self._default_headers = default_headers or {}
self._client = httpx.Client(timeout=30.0)
self._refresh_only = refresh_only
self._refresh_lock = threading.Lock()

def _get_token(self) -> str | None:
if self._get_access_token:
return self._get_access_token()
return self._access_token

def _transform_path(self, path: str) -> str:
if self._path_prefix is None:
return path
if path.startswith(DEFAULT_API_PREFIX):
return self._path_prefix + path[len(DEFAULT_API_PREFIX) :]
return path

def set_access_token(self, token: str) -> None:
self._access_token = token

def set_refresh_handler(self, handler: Callable[[], str]) -> None:
self._on_refresh_token = handler

def _do_fetch(
self,
path: str,
*,
method: str = "GET",
body: Any = None,
headers: dict[str, str] | None = None,
query: dict[str, Any] | None = None,
) -> httpx.Response:
token = self._get_token()
url = f"{self._base_url}{self._transform_path(path)}"

req_headers = {
**self._default_headers,
"Content-Type": "application/json",
}
if token:
req_headers["Authorization"] = f"Bearer {token}"
if headers:
req_headers.update(headers)

params = None
if query:
params = {k: v for k, v in query.items() if v is not None}

return self._client.request(
method,
url,
json=body if body is not None and method not in ("GET", "HEAD") else None,
headers=req_headers,
params=params,
)

def _execute(
self,
path: str,
*,
method: str = "GET",
body: Any = None,
headers: dict[str, str] | None = None,
query: dict[str, Any] | None = None,
) -> httpx.Response:
auth_prefix = f"{DEFAULT_API_PREFIX}/auth/"
if self._refresh_only and not path.startswith(auth_prefix):
raise RuntimeError(
f"Refresh-only HTTP client cannot make requests outside {auth_prefix}"
)

original_token = self._get_token()
response = self._do_fetch(path, method=method, body=body, headers=headers, query=query)

if (
response.status_code == 401
and self._on_refresh_token
and not path.startswith(auth_prefix)
):
try:
with self._refresh_lock:
if self._get_token() == original_token:
self._access_token = self._on_refresh_token()
except Exception:
pass
else:
response = self._do_fetch(
path, method=method, body=body, headers=headers, query=query
)

if response.status_code >= 400:
raw_data: dict[str, Any] = {}
try:
raw_data = response.json()
except Exception:
pass
error_code, message = _parse_error(raw_data, response.status_code)
raise ApiError(response.status_code, error_code, message, raw_data)

return response

def request(
self,
path: str,
*,
method: str = "GET",
body: Any = None,
headers: dict[str, str] | None = None,
query: dict[str, Any] | None = None,
) -> Any:
response = self._execute(path, method=method, body=body, headers=headers, query=query)

if response.status_code == 204:
return None

return response.json()

def request_raw(
self,
path: str,
*,
method: str = "GET",
body: Any = None,
headers: dict[str, str] | None = None,
query: dict[str, Any] | None = None,
) -> dict[str, Any]:
response = self._execute(path, method=method, body=body, headers=headers, query=query)

return {
"content": response.content,
"mime_type": response.headers.get("content-type", "text/plain"),
}

def close(self) -> None:
self._client.close()


def _parse_error(raw_data: dict[str, Any], status: int) -> tuple[str, str]:
error = raw_data.get("error")
if isinstance(error, dict):
Expand Down
129 changes: 127 additions & 2 deletions tests/test_http_client.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
# Copyright (c) 2026 ArchAstro Inc. All Rights Reserved.
"""Unit tests for HttpClient 401 auto-refresh — mirrors the TS test suite."""

from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock, Mock, patch

import httpx
import pytest

from archastro.platform.runtime.http_client import ApiError, HttpClient
from archastro.platform.runtime.http_client import ApiError, HttpClient, SyncHttpClient


def _mock_response(status: int, body: dict | None = None) -> httpx.Response:
Expand Down Expand Up @@ -253,3 +253,128 @@ async def refresh_handler():

assert result == {"id": "ok"}
assert attempt == 2


def test_sync_client_sends_auth_headers_query_and_json_body():
client = SyncHttpClient(
base_url="https://api.test",
access_token="sat_test",
path_prefix="/proxy/v1",
default_headers={"x-archastro-api-key": "pk_test"},
)

with patch.object(
client._client,
"request",
return_value=_mock_response(200, {"ok": True}),
) as request:
result = client.request(
"/api/v1/things",
method="POST",
body={"name": "demo"},
query={"limit": 10, "empty": None},
)

assert result == {"ok": True}
request.assert_called_once_with(
"POST",
"https://api.test/proxy/v1/things",
json={"name": "demo"},
headers={
"x-archastro-api-key": "pk_test",
"Content-Type": "application/json",
"Authorization": "Bearer sat_test",
},
params={"limit": 10},
)


def test_sync_client_retries_with_new_token_after_401():
refresh_handler = Mock(return_value="fresh-token")
client = SyncHttpClient(
base_url="https://api.test",
access_token="expired-token",
on_refresh_token=refresh_handler,
)
responses = [
_mock_response(401, {"error": "unauthenticated", "message": "expired"}),
_mock_response(200, {"id": "123"}),
]

with patch.object(client._client, "request", side_effect=responses) as request:
result = client.request("/api/v1/things")

assert result == {"id": "123"}
assert request.call_count == 2
refresh_handler.assert_called_once_with()
assert request.call_args_list[1].kwargs["headers"]["Authorization"] == "Bearer fresh-token"


def test_sync_client_does_not_retry_auth_paths():
refresh_handler = Mock(return_value="fresh-token")
client = SyncHttpClient(
base_url="https://api.test",
access_token="expired-token",
on_refresh_token=refresh_handler,
)

with patch.object(
client._client,
"request",
return_value=_mock_response(401, {"error": "unauthenticated"}),
):
with pytest.raises(ApiError) as exc_info:
client.request("/api/v1/auth/refresh", method="POST")

assert exc_info.value.status == 401
refresh_handler.assert_not_called()


def test_sync_refresh_only_client_throws_on_non_auth_paths():
client = SyncHttpClient(base_url="https://api.test", refresh_only=True)

with pytest.raises(RuntimeError, match="Refresh-only HTTP client"):
client.request("/api/v1/agents")

with patch.object(
client._client,
"request",
return_value=_mock_response(200, {"token": "t"}),
):
result = client.request("/api/v1/auth/refresh", method="POST")

assert result == {"token": "t"}


def test_sync_client_request_raw_returns_bytes_and_mime_type():
client = SyncHttpClient(base_url="https://api.test")
response = httpx.Response(
status_code=200,
content=b"hello",
headers={"content-type": "text/plain"},
request=httpx.Request("GET", "https://api.test"),
)

with patch.object(client._client, "request", return_value=response):
result = client.request_raw("/api/v1/files/file_123/download")

assert result == {"content": b"hello", "mime_type": "text/plain"}


def test_sync_client_raises_structured_api_error():
client = SyncHttpClient(base_url="https://api.test")

with patch.object(
client._client,
"request",
return_value=_mock_response(
403,
{"error": {"code": "forbidden", "message": "no access"}},
),
):
with pytest.raises(ApiError) as exc_info:
client.request("/api/v1/things")

assert exc_info.value.status == 403
assert exc_info.value.error_code == "forbidden"
assert str(exc_info.value) == "no access"
Loading