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
- 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
219 lines
6.8 KiB
Python
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)
|