Skip to content
Open
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
13 changes: 13 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# TODO
- [ ] Refactor `src/shade/http.py`:
- [ ] Add shared `_build_request(...)` helper for URL + headers + JSON body
- [ ] Add `_AsyncHTTPClient` using `httpx.AsyncClient` with `async def request(...)`
- [ ] Implement async lifecycle management (async with / awaited `aclose()`)
- [ ] Keep sync behavior unchanged and ensure both return same response shape
- [x] Update `src/shade/http.py` public `AsyncHTTPClient` to delegate to `_AsyncHTTPClient`
- [x] Update `tests/test_rate_limit.py` to mock `httpx.AsyncClient` instead of `aiohttp`
- [x] Add `httpx` dependency to `pyproject.toml`
- [x] Run test suite: `pytest`
- [x] Verify async tests don’t create event-loop conflicts (pytest-asyncio compatible)


1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ flake8 = "^6.1.0"
black = "^23.7.0"
isort = "^5.12.0"
aiohttp = "^3.14.1"
httpx = "^0.27.0"


[build-system]
Expand Down
173 changes: 111 additions & 62 deletions src/shade/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,37 @@ def _raise_for_status(
raise HTTPError(f"HTTP {status}: {detail}".strip(), status_code=status)


# ---------------------------------------------------------------------------
# Shared request-building
# ---------------------------------------------------------------------------

def _build_url(base_url: str, path: str) -> str:
return f"{base_url}/{path.lstrip('/')}"


def _build_headers(api_key: str) -> Dict[str, str]:
return {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"Accept": "application/json",
}


def _build_request(
method: str,
base_url: str,
path: str,
payload: Optional[Dict[str, Any]],
) -> Tuple[str, str, Dict[str, str], Optional[Dict[str, Any]]]:
"""Shared URL + header + payload helper for sync and async clients."""
url = _build_url(base_url, path)
headers = _build_headers(api_key="")
return method.upper(), url, headers, payload





# ---------------------------------------------------------------------------
# Synchronous client
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -144,14 +175,23 @@ def __init__(
def _build_request(
self, method: str, path: str, payload: Optional[Dict[str, Any]]
) -> urllib.request.Request:
url = f"{self.base_url}/{path.lstrip('/')}"
method_u, url, headers, data = _build_request(
method,
self.base_url,
path,
payload,
)
# inject headers with api_key
headers = _build_headers(self.api_key)
data = json.dumps(payload).encode("utf-8") if payload is not None else None
req = urllib.request.Request(url, data=data, method=method.upper())
req.add_header("Authorization", f"Bearer {self.api_key}")
req.add_header("Content-Type", "application/json")
req.add_header("Accept", "application/json")


req = urllib.request.Request(url, data=data, method=method_u)
for k, v in headers.items():
req.add_header(k, v)
return req


def request(
self,
method: str,
Expand Down Expand Up @@ -198,19 +238,15 @@ def _execute(


# ---------------------------------------------------------------------------
# Asynchronous client
# Asynchronous client (httpx)
# ---------------------------------------------------------------------------

class AsyncHTTPClient:
"""
Async counterpart of ``SyncHTTPClient``. Uses ``aiohttp`` under the hood.

Parameters
----------
Same as ``SyncHTTPClient``.
"""
class _AsyncHTTPClient:
"""Async counterpart of ``SyncHTTPClient`` using ``httpx.AsyncClient``."""

def __init__(


self,
base_url: str,
api_key: str,
Expand All @@ -222,65 +258,78 @@ def __init__(
self.api_key = api_key
self.max_retries = max_retries
self.timeout = timeout
self._client = None

async def _get_client(self):
import httpx

if self._client is None:
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(self.timeout),
headers=_build_headers(self.api_key),
)

return self._client

async def aclose(self) -> None:
if self._client is not None:
await self._client.aclose()
self._client = None

async def request(
self,
method: str,
path: str,
payload: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""
Async HTTP request with 429 retry using ``asyncio.sleep``.

Returns
-------
dict
Parsed JSON response body.

Raises
------
RateLimitError, HTTPError
Same semantics as ``SyncHTTPClient.request``.
ImportError
If ``aiohttp`` is not installed.
"""
import asyncio # stdlib — always available
import asyncio

try:
import aiohttp
import httpx # noqa: F401
except ImportError as exc:
raise ImportError(
"aiohttp is required for async support. "
"Install it with: pip install aiohttp"
"httpx is required for async HTTP support. "
"Install it with: pip install httpx"
) from exc

url_base = self.base_url
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
"Accept": "application/json",
}
connector = aiohttp.TCPConnector()
timeout_cfg = aiohttp.ClientTimeout(total=self.timeout)

attempt = 0
async with aiohttp.ClientSession(
connector=connector, timeout=timeout_cfg
) as session:
while True:
url = f"{url_base}/{path.lstrip('/')}"
resp = await session.request(
method.upper(),
url,
json=payload,
headers=headers,
)
body = await resp.read()
wait = _raise_for_status(
resp.status, resp.headers, body, attempt, self.max_retries
)
if wait is None:
return json.loads(body) if body else {}
# 429 — non-blocking sleep
await asyncio.sleep(wait)
attempt += 1
while True:
url = _build_url(self.base_url, path)
client = await self._get_client()

req_method = method.upper()

# Use httpx request with json=payload
resp = await client.request(req_method, url, json=payload)


body = resp.content


wait = _raise_for_status(
resp.status_code, resp.headers, body, attempt, self.max_retries
)
if wait is None:
return json.loads(body) if body else {}
await asyncio.sleep(wait)
attempt += 1


class AsyncHTTPClient(_AsyncHTTPClient):
"""Public async client (httpx-based)."""

def __init__(
self,
base_url: str,
api_key: str,
max_retries: int = DEFAULT_MAX_RETRIES,
timeout: float = 30.0,
) -> None:
super().__init__(
base_url=base_url,
api_key=api_key,
max_retries=max_retries,
timeout=timeout,
)


Loading