From 4d83d2e7543d1891820336ea7564e4027f7c70ec Mon Sep 17 00:00:00 2001 From: Calvin Grunewald Date: Fri, 12 Jun 2026 07:46:31 -0700 Subject: [PATCH] feat: add sync python http client --- src/archastro/platform/runtime/http_client.py | 155 ++++++++++++++++++ tests/test_http_client.py | 129 ++++++++++++++- 2 files changed, 282 insertions(+), 2 deletions(-) diff --git a/src/archastro/platform/runtime/http_client.py b/src/archastro/platform/runtime/http_client.py index c86082a..3cd182e 100644 --- a/src/archastro/platform/runtime/http_client.py +++ b/src/archastro/platform/runtime/http_client.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio +import threading from collections.abc import Callable, Coroutine from typing import Any @@ -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): diff --git a/tests/test_http_client.py b/tests/test_http_client.py index 53d2e79..1635066 100644 --- a/tests/test_http_client.py +++ b/tests/test_http_client.py @@ -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: @@ -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"