krz/hutch-stats

Server-side utility for calculating contributions for sourcehut users.

clone: git clone https://gitbay.org/krz/hutch-stats.git

main: src/srht_contrib/services/srht_client.py · raw

 1from __future__ import annotations
 2
 3import logging
 4import time
 5from typing import Any
 6
 7import httpx
 8
 9
10logger = logging.getLogger(__name__)
11
12
13class SourceHutClientError(RuntimeError):
14    """Raised when a SourceHut GraphQL request fails."""
15
16
17def _graphql_error_summary(errors: Any) -> str:
18    if not isinstance(errors, list):
19        return "unexpected error payload"
20    return f"{len(errors)} GraphQL error(s)"
21
22
23class SourceHutGraphQLClient:
24    def __init__(
25        self,
26        endpoint: str,
27        token: str,
28        *,
29        timeout: float = 15.0,
30        max_retries: int = 2,
31        request_delay: float = 0.5,
32        transport: httpx.BaseTransport | None = None,
33    ) -> None:
34        self.endpoint = endpoint
35        self.timeout = timeout
36        self.max_retries = max_retries
37        self.request_delay = request_delay
38        headers = {
39            "Authorization": f"Bearer {token}",
40            "Content-Type": "application/json",
41        }
42        self._client = httpx.Client(headers=headers, timeout=timeout, transport=transport)
43
44    def execute(self, query: str, variables: dict[str, Any] | None = None) -> dict[str, Any]:
45        payload = {"query": query, "variables": variables or {}}
46        attempts = self.max_retries + 1
47
48        for attempt in range(attempts):
49            if attempt > 0:
50                time.sleep(2 ** (attempt - 1))
51            elif self.request_delay > 0:
52                time.sleep(self.request_delay)
53
54            try:
55                response = self._client.post(self.endpoint, json=payload)
56                response.raise_for_status()
57                body = response.json()
58            except httpx.HTTPStatusError as exc:
59                logger.warning(
60                    "SourceHut HTTP failure from %s on attempt %s/%s: status=%s",
61                    self.endpoint,
62                    attempt + 1,
63                    attempts,
64                    exc.response.status_code,
65                )
66                if exc.response.status_code >= 500 and attempt < attempts - 1:
67                    continue
68                raise SourceHutClientError(f"HTTP error from SourceHut: {exc.response.status_code}") from exc
69            except httpx.HTTPError as exc:
70                logger.warning(
71                    "SourceHut network failure from %s on attempt %s/%s",
72                    self.endpoint,
73                    attempt + 1,
74                    attempts,
75                )
76                if attempt < attempts - 1:
77                    continue
78                raise SourceHutClientError("Network error while contacting SourceHut") from exc
79
80            if "errors" in body:
81                logger.warning(
82                    "SourceHut GraphQL failure from %s: %s",
83                    self.endpoint,
84                    _graphql_error_summary(body["errors"]),
85                )
86                raise SourceHutClientError(
87                    f"GraphQL errors returned by SourceHut: {_graphql_error_summary(body['errors'])}"
88                )
89            return body.get("data", {})
90
91        raise SourceHutClientError("SourceHut request exhausted retries")
92
93    def close(self) -> None:
94        self._client.close()