blob: e6f3cd52926d98efb81dccb9a4165002082ca4a9 [file]
#!/usr/bin/env python3
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""A rate limit aware GitHub GraphQL client.
Everything the scanner knows about GitHub's failure modes lives here:
* the hourly GraphQL point budget, read from the rateLimit block that every
scanner query asks for (asking is free),
* primary rate limiting, where the client parks until the budget resets,
* secondary rate limiting, where it honours Retry-After and x-ratelimit-reset,
* GitHub timing out on large pages. That arrives either as a 502 or as a 200
carrying "Something went wrong while executing your query", and the answer is
to shrink the page size rather than send the same doomed query again.
"""
from __future__ import annotations
import asyncio
import dataclasses
import datetime
import logging
import random
import time
import typing
import aiohttp
from . import config as _config
from .ghtypes import GraphQLData, as_dict, as_list, as_str
LOGGER = logging.getLogger("orgscanner.github")
# GitHub's wording when a query exceeds its internal execution deadline.
TIMEOUT_MARKERS = (
"something went wrong while executing your query",
"timeout",
"timed out",
)
class GitHubError(Exception):
"""A GraphQL query failed in a way that is not worth retrying."""
class GitHubQueryError(GitHubError):
"""GitHub returned an errors block we cannot recover from."""
def __init__(self, message: str, errors: list[typing.Any] | None = None):
super().__init__(message)
self.errors: list[typing.Any] = errors or []
class _Transient(Exception):
"""Internal: a failure that is worth another attempt."""
def __init__(self, reason: str, retry_after: float | None = None, timeout: bool = False):
super().__init__(reason)
self.reason = reason
self.retry_after = retry_after
self.timeout = timeout
@dataclasses.dataclass
class RateLimit:
"""The last known state of the hourly GraphQL point budget."""
limit: int = 5000
remaining: int = 5000
used: int = 0
cost: int = 0
reset_at: float = 0.0
updated_at: float = 0.0
@property
def known(self) -> bool:
return self.updated_at > 0
def seconds_until_reset(self) -> float:
return max(0.0, self.reset_at - time.time())
class AdaptivePageSize:
"""A page size that shrinks when GitHub times out and creeps back up.
GitHub's GraphQL endpoint gets flaky on large pages for big organizations;
Boxer's own team loader had to be pinned at 20 results for that reason.
Rather than pinning everything low forever, each paginated sweep gets one of
these. It starts at the configured size, halves on a timeout, and grows back
after a run of clean responses.
"""
def __init__(self, size: int, minimum: int = 5, label: str = "page"):
self.initial = max(1, int(size))
self.minimum = max(1, min(int(minimum), self.initial))
self.size = self.initial
self.label = label
self.shrinks = 0
self._clean_runs = 0
def shrink(self) -> int:
previous = self.size
self.size = max(self.minimum, self.size // 2)
self._clean_runs = 0
if self.size != previous:
self.shrinks += 1
LOGGER.warning("GitHub timed out on %s; reducing page size %d -> %d", self.label, previous, self.size)
return self.size
def success(self) -> None:
if self.size >= self.initial:
return
self._clean_runs += 1
if self._clean_runs >= 5:
previous = self.size
self.size = min(self.initial, max(self.size + 1, int(self.size * 1.5)))
self._clean_runs = 0
LOGGER.info("Growing %s page size %d -> %d after a clean run", self.label, previous, self.size)
@typing.runtime_checkable
class QueryClient(typing.Protocol):
"""What the scanner needs from a GraphQL client.
GraphQLClient below is the implementation; the scanner takes the protocol so
tests can hand it a fake endpoint and still be type checked.
"""
requests: int
points_spent: int
retries: int
rate_limit: RateLimit
async def query(
self,
document: str,
variables: typing.Mapping[str, typing.Any] | None = None,
*,
label: str = ...,
page_size: AdaptivePageSize | None = ...,
page_size_variable: str = ...,
) -> GraphQLData: ...
async def session(self) -> object: ...
async def close(self) -> None: ...
class GraphQLClient:
"""Async GraphQL client with retries, pacing and rate limit parking."""
def __init__(self, config: _config.Configuration, session: aiohttp.ClientSession | None = None):
self.config = config
self.rate_limit = RateLimit()
self.requests = 0
self.points_spent = 0
self.retries = 0
self._session = session
self._owns_session = session is None
self._semaphore = asyncio.Semaphore(config.performance.concurrency)
# Serialises the "everyone hold off" decision, so concurrent queries
# wait out one reset window between them.
self._gate = asyncio.Lock()
self._pace_lock = asyncio.Lock()
self._last_request = 0.0
# -- session plumbing ---------------------------------------------------
@property
def headers(self) -> dict[str, str]:
return {
"Authorization": "bearer %s" % self.config.github.token,
"User-Agent": self.config.github.user_agent,
"Accept": "application/json",
}
async def session(self) -> aiohttp.ClientSession:
if self._session is None or self._session.closed:
timeout = aiohttp.ClientTimeout(
total=None, sock_connect=30, sock_read=self.config.performance.request_timeout
)
self._session = aiohttp.ClientSession(headers=self.headers, timeout=timeout)
self._owns_session = True
return self._session
async def close(self) -> None:
if self._session is not None and self._owns_session and not self._session.closed:
await self._session.close()
self._session = None
async def __aenter__(self) -> GraphQLClient:
await self.session()
return self
async def __aexit__(self, exc_type: object, exc_val: object, exc_tb: object) -> None:
await self.close()
# -- pacing and rate limiting ------------------------------------------
async def _sleep(self, seconds: float, reason: str) -> None:
seconds = min(max(seconds, 0.0), float(self.config.performance.max_sleep))
if seconds <= 0:
return
LOGGER.warning("Pausing GitHub queries for %.0f seconds: %s", seconds, reason)
await asyncio.sleep(seconds)
async def _await_budget(self) -> None:
"""Park if the hourly point budget is down to the configured reserve.
The wait happens while holding the gate, and the state is checked again
after acquiring it, so a hundred queries hitting the reserve at once
wait out one reset window between them instead of one each.
"""
reserve = self.config.performance.rate_limit_reserve
if not self.rate_limit.known or reserve <= 0:
return
if self.rate_limit.remaining > reserve:
return
async with self._gate:
if self.rate_limit.remaining > reserve: # Someone else already waited
return
wait = self.rate_limit.seconds_until_reset() + 5
if wait > 0:
await self._sleep(
wait,
"GraphQL budget down to %d/%d points (reserve %d)"
% (self.rate_limit.remaining, self.rate_limit.limit, reserve),
)
# Assume the budget came back; the next response will correct us.
self.rate_limit.remaining = self.rate_limit.limit
self.rate_limit.reset_at = time.time() + 3600
async def _pace(self) -> None:
interval = self.config.performance.min_request_interval
if interval <= 0:
return
async with self._pace_lock:
delta = time.time() - self._last_request
if delta < interval:
await asyncio.sleep(interval - delta)
self._last_request = time.time()
def _note_rate_limit(self, data: GraphQLData) -> None:
block = as_dict(data.get("rateLimit"))
if not block:
return
cost = _as_number(block.get("cost"), 0)
self.points_spent += cost
self.rate_limit.cost = cost
self.rate_limit.limit = _as_number(block.get("limit"), self.rate_limit.limit)
self.rate_limit.remaining = _as_number(block.get("remaining"), self.rate_limit.remaining)
self.rate_limit.used = _as_number(block.get("used"), self.rate_limit.used)
reset_at = as_str(block.get("resetAt"))
if reset_at:
self.rate_limit.reset_at = parse_timestamp(reset_at)
self.rate_limit.updated_at = time.time()
# -- querying ----------------------------------------------------------
async def query(
self,
document: str,
variables: typing.Mapping[str, typing.Any] | None = None,
*,
label: str = "query",
page_size: AdaptivePageSize | None = None,
page_size_variable: str = "size",
) -> GraphQLData:
"""Run a GraphQL document, retrying transient failures.
If page_size is given, a GitHub timeout shrinks it and the retry goes
out with the smaller page, instead of repeating the query that just blew
GitHub's execution deadline.
"""
query_variables: dict[str, typing.Any] = dict(variables or {})
attempt = 0
while True:
await self._await_budget()
await self._pace()
try:
async with self._semaphore:
data = await self._post(document, query_variables, label)
except _Transient as transient:
attempt += 1
self.retries += 1
if transient.timeout and page_size is not None:
query_variables[page_size_variable] = page_size.shrink()
if attempt > self.config.performance.max_retries:
raise GitHubError(
f"{label}: giving up after {attempt} attempts, last error: {transient.reason}"
) from transient
delay = transient.retry_after
if delay is None:
backoff = self.config.performance.retry_backoff**attempt
delay = min(backoff, 60.0) + random.uniform(0, 1)
LOGGER.warning(
"%s: %s (attempt %d/%d), retrying in %.1fs",
label,
transient.reason,
attempt,
self.config.performance.max_retries,
delay,
)
await asyncio.sleep(min(delay, float(self.config.performance.max_sleep)))
continue
if page_size is not None:
page_size.success()
return data
async def _post(self, document: str, variables: dict[str, typing.Any], label: str) -> GraphQLData:
session = await self.session()
payload = {"query": document, "variables": variables}
try:
async with session.post(self.config.github.graphql_url, json=payload) as response:
self.requests += 1
if response.status in (500, 502, 503, 504):
body = (await response.text())[:200]
raise _Transient(f"HTTP {response.status} from GitHub: {body}", timeout=True)
if response.status in (403, 429):
raise _Transient(
"HTTP %d (rate limited or abuse detection)" % response.status,
retry_after=_retry_after(response.headers),
)
if response.status == 401:
raise GitHubError("GitHub rejected the token (HTTP 401), check github.token")
if response.status != 200:
body = (await response.text())[:200]
raise GitHubError(f"Unexpected HTTP {response.status} from GitHub: {body}")
try:
js = as_dict(await response.json())
except (aiohttp.ContentTypeError, ValueError) as e:
raise _Transient(f"Could not decode GitHub response as JSON: {e}") from e
except asyncio.TimeoutError as e:
raise _Transient("request timed out", timeout=True) from e
except aiohttp.ClientError as e:
raise _Transient(f"connection error: {e}") from e
data = as_dict(js.get("data"))
self._note_rate_limit(data)
errors = as_list(js.get("errors"))
if errors:
self._raise_for_errors(errors, data, label)
if not data:
raise _Transient("GitHub returned an empty data block")
return data
def _raise_for_errors(self, errors: list[typing.Any], data: GraphQLData, label: str) -> None:
"""Sort a GraphQL errors block into retry, give up, or carry on."""
messages = [as_str(as_dict(error).get("message"), str(error)) for error in errors]
joined = "; ".join(messages)
lowered = joined.lower()
types = {as_str(as_dict(error).get("type")) for error in errors}
if "RATE_LIMITED" in types or "api rate limit exceeded" in lowered:
wait = self.rate_limit.seconds_until_reset() + 5 if self.rate_limit.known else 300
raise _Transient(f"rate limited: {joined}", retry_after=wait)
if any(marker in lowered for marker in TIMEOUT_MARKERS):
raise _Transient(f"GitHub query timed out: {joined}", timeout=True)
if "NOT_FOUND" in types:
raise GitHubQueryError(f"{label}: {joined}", errors)
if data:
# Partial data with a soft error, such as a suspended account inside
# a connection, is common and survivable. Log it and keep the data.
LOGGER.warning("%s: GitHub reported errors but returned data: %s", label, joined)
return
raise _Transient(f"GitHub error: {joined}")
def _as_number(value: object, default: int) -> int:
"""Read an integer out of a rateLimit block."""
if isinstance(value, bool) or not isinstance(value, (int, float)):
return default
return int(value)
def _retry_after(headers: typing.Mapping[str, str]) -> float | None:
"""Work out how long GitHub wants us to wait, from its documented headers."""
retry_after = headers.get("Retry-After")
if retry_after:
try:
return float(retry_after)
except ValueError:
pass
if headers.get("x-ratelimit-remaining") == "0":
reset = headers.get("x-ratelimit-reset")
if reset:
try:
return max(0.0, float(reset) - time.time()) + 5
except ValueError:
pass
return 60.0 # GitHub's guidance for a secondary limit with no hint attached
def parse_timestamp(value: str) -> float:
"""Parse an ISO-8601 timestamp. GitHub always sends UTC stamps ending in Z.
An unparseable stamp becomes an hour from now rather than the current time,
so a caller waiting for a rate limit reset does not stop waiting early.
"""
try:
return datetime.datetime.fromisoformat(str(value).replace("Z", "+00:00")).timestamp()
except (TypeError, ValueError):
return time.time() + 3600