Files
stack/src/api/clients/base.py
kert b361cd1880
Some checks failed
CI / skinny-install (aco) (push) Successful in 1m11s
CI / skinny-install (api) (push) Successful in 37s
CI / skinny-install (bcda) (push) Successful in 34s
CI / skinny-install (bib) (push) Successful in 31s
CI / skinny-install (bls) (push) Successful in 29s
CI / skinny-install (ccw) (push) Successful in 33s
CI / skinny-install (cli) (push) Successful in 33s
CI / skinny-install (cms) (push) Successful in 30s
CI / skinny-install (conf) (push) Successful in 32s
CI / skinny-install (opps) (push) Successful in 36s
CI / skinny-install (perf) (push) Successful in 37s
CI / lint-test (push) Has started running
CI / skinny-install (pfs) (push) Has started running
CI / skinny-install (rex) (push) Has been cancelled
Deploy / build-scan-report (push) Has been cancelled
Infra CI / notebooks (push) Has been cancelled
Infra CI / zotero (push) Has been cancelled
Infra CI / docs (push) Has been cancelled
Infra CI / api (push) Has been cancelled
Infra CI / mc (push) Has been cancelled
fix(ci): complete gitea→git rename in dev scripts + API base
- pkg_mirror_sync.py: read `git =` from stack.toml (was `gitea`),
  fallback http://git:3000 (was gitea:3000)
- bootstrap_sso.py, backends/woodpecker.py, api/clients/base.py:
  all gitea:3000 → git:3000
2026-04-16 14:05:30 -04:00

219 lines
6.8 KiB
Python

"""Shared HTTP client with retry, auth, and error handling."""
from __future__ import annotations
import logging
import time
import httpx
log = logging.getLogger(__name__)
MAX_RETRIES = 3
RETRY_INITIAL_INTERVAL = 1.0 # seconds
class ApiError(Exception):
"""Base exception for API client errors."""
class Client:
"""Base HTTP client with retry and auth hooks.
Parameters
----------
base_url : str
API base URL (e.g. ``http://git:3000/api/v1``).
max_retries : int
Max retry attempts for transient errors.
retry_interval : float
Initial back-off interval in seconds (doubles each retry).
timeout : float
HTTP request timeout in seconds.
_transport : httpx.BaseTransport | None
Injected transport for testing (``httpx.MockTransport``).
"""
def __init__(
self,
base_url: str,
*,
max_retries: int = MAX_RETRIES,
retry_interval: float = RETRY_INITIAL_INTERVAL,
timeout: float = 30.0,
_transport: httpx.BaseTransport | None = None,
) -> None:
self.base_url = base_url.rstrip("/")
self.max_retries = max_retries
self.retry_interval = retry_interval
self._http = httpx.Client(
timeout=timeout,
**({"transport": _transport} if _transport else {}),
)
def close(self) -> None:
"""Close the underlying HTTP client."""
self._http.close()
def __enter__(self) -> Client:
return self
def __exit__(self, *exc: object) -> None:
self.close()
# ── Auth hooks (override in subclasses) ──────────────────
def _default_headers(self) -> dict[str, str]:
"""Return headers included on every request.
Override to add static auth tokens.
"""
return {}
def authenticate(self) -> None:
"""Perform dynamic authentication (e.g. token refresh).
No-op by default. Override for OAuth / dynamic token flows.
"""
# ── Low-level request with retry ─────────────────────────
def _request(
self,
method: str,
path: str,
*,
headers: dict[str, str] | None = None,
params: dict[str, str] | None = None,
json: object = None,
content: bytes | None = None,
auth_required: bool = True,
retry: bool = True,
) -> httpx.Response:
"""Execute an HTTP request with retry and token refresh.
Parameters
----------
method : str
HTTP method.
path : str
Path relative to ``base_url``.
headers : dict | None
Extra headers merged with ``_default_headers()``.
params : dict | None
Query parameters.
json : object
JSON-serializable body.
content : bytes | None
Raw body bytes.
auth_required : bool
Whether to include auth headers.
retry : bool
Whether to apply retry logic.
"""
url = f"{self.base_url}{path}"
merged: dict[str, str] = {}
if auth_required:
merged.update(self._default_headers())
if headers:
merged.update(headers)
attempts = self.max_retries if retry else 1
backoff = self.retry_interval
last_exc: BaseException | None = None
refreshed = False
for attempt in range(1, attempts + 1):
try:
resp = self._http.request(
method,
url,
headers=merged,
params=params,
json=json,
content=content,
)
except (httpx.ConnectError, httpx.ReadTimeout) as exc:
last_exc = exc
if attempt < attempts:
log.warning(
"%s %s attempt %d/%d: %s — retrying in %.1fs",
method,
url,
attempt,
attempts,
exc,
backoff,
)
time.sleep(backoff)
backoff *= 2
continue
raise ApiError(
f"{method} {url} failed after {attempts} attempts"
) from exc
if resp.status_code == 401 and auth_required and not refreshed:
log.info("Got 401, re-authenticating")
self.authenticate()
merged.update(self._default_headers())
refreshed = True
continue
if resp.status_code == 429:
delay = int(resp.headers.get("Retry-After", "60"))
log.warning(
"Rate limited (429), sleeping %ds (attempt %d/%d)",
delay,
attempt,
attempts,
)
time.sleep(delay)
continue
if resp.status_code >= 500:
last_exc = httpx.HTTPStatusError(
f"Server error {resp.status_code}",
request=resp.request,
response=resp,
)
if attempt < attempts:
log.warning(
"%s %s returned %d (attempt %d/%d) — retrying",
method,
url,
resp.status_code,
attempt,
attempts,
)
time.sleep(backoff)
backoff *= 2
continue
resp.raise_for_status()
resp.raise_for_status()
return resp
if last_exc:
raise ApiError(str(last_exc)) from last_exc
raise ApiError("Request failed with no response")
# ── Convenience verbs ────────────────────────────────────
def get(
self, path: str, *, params: dict[str, str] | None = None, **kw
) -> httpx.Response:
return self._request("GET", path, params=params, **kw)
def post(self, path: str, *, json: object = None, **kw) -> httpx.Response:
return self._request("POST", path, json=json, **kw)
def put(self, path: str, *, json: object = None, **kw) -> httpx.Response:
return self._request("PUT", path, json=json, **kw)
def delete(self, path: str, **kw) -> httpx.Response:
return self._request("DELETE", path, **kw)
def patch(self, path: str, *, json: object = None, **kw) -> httpx.Response:
return self._request("PATCH", path, json=json, **kw)