diff --git a/scripts/ci_macos_admission.py b/scripts/ci_macos_admission.py index 71876c505c..0b5089ac2b 100644 --- a/scripts/ci_macos_admission.py +++ b/scripts/ci_macos_admission.py @@ -131,6 +131,10 @@ class Api: def __init__(self, token, url="https://api.github.com"): self.token = token self.url = url.rstrip("/") + # The token's hourly limit is shared by every workflow in the repository, + # so what the last response says is left shows their use too. + self.calls = 0 + self.remaining = self.limit = "?" def get(self, path, **params): query = urllib.parse.urlencode(params) @@ -139,8 +143,22 @@ class Api: "Authorization": f"Bearer {self.token}", "X-GitHub-Api-Version": "2022-11-28", }) - with urllib.request.urlopen(request, timeout=30) as response: - return json.load(response) + self.calls += 1 + try: + with urllib.request.urlopen(request, timeout=30) as response: + self.note_rate_limit(response.headers) + return json.load(response) + except urllib.error.HTTPError as error: + # A rate-limited request fails, and its headers say so. + self.note_rate_limit(error.headers) + raise + + def note_rate_limit(self, headers): + self.remaining = headers.get("X-RateLimit-Remaining", "?") + self.limit = headers.get("X-RateLimit-Limit", "?") + + def usage(self, calls_before): + return f" API calls this check: {self.calls - calls_before}, rate limit left: {self.remaining} of {self.limit}" def list_runs(api, repo, workflow, **params): @@ -227,9 +245,19 @@ def main(): api = Api(os.environ["GH_TOKEN"], os.environ.get("GITHUB_API_URL", "https://api.github.com")) run_id = int(os.environ["GITHUB_RUN_ID"]) limit = int(os.environ.get("MACOS_RUNNER_LIMIT") or 5) + + def measure_now(): + calls_before = api.calls + try: + total, lines = measure(api, repo, workflow, run_id, datetime.datetime.now(datetime.timezone.utc), + priority=os.environ.get("PRIORITY") == "true") + except Exception: + print(api.usage(calls_before).strip()) + raise + return total, lines + [api.usage(calls_before)] + reason = wait( - lambda: measure(api, repo, workflow, run_id, datetime.datetime.now(datetime.timezone.utc), - priority=os.environ.get("PRIORITY") == "true"), + measure_now, limit, wait_minutes=int(os.environ.get("WAIT_MINUTES") or 240), poll_seconds=int(os.environ.get("POLL_SECONDS") or 180), diff --git a/scripts/tests/test_ci_macos_admission.py b/scripts/tests/test_ci_macos_admission.py index 29e80e3a25..b872545d3f 100644 --- a/scripts/tests/test_ci_macos_admission.py +++ b/scripts/tests/test_ci_macos_admission.py @@ -9,6 +9,7 @@ import os import sys import unittest import urllib.error +from unittest import mock sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) @@ -289,6 +290,41 @@ class MeasureTest(unittest.TestCase): self.assertFalse(any("event" in params for _, params in api.calls)) +class ApiTest(unittest.TestCase): + class Response: + def __init__(self, headers): + self.headers = headers + + def __enter__(self): + return self + + def __exit__(self, *_): + return False + + def read(self): + return b"{}" + + def test_reports_calls_and_the_rate_limit_left(self): + api = admission.Api("token") + responses = [self.Response({}), + self.Response({"X-RateLimit-Remaining": "812", "X-RateLimit-Limit": "1000"})] + with mock.patch.object(admission.urllib.request, "urlopen", side_effect=responses): + api.get("first") + calls_before = api.calls + api.get("second") + self.assertEqual(api.usage(0), " API calls this check: 2, rate limit left: 812 of 1000") + self.assertEqual(api.usage(calls_before), " API calls this check: 1, rate limit left: 812 of 1000") + + def test_reads_the_rate_limit_from_a_failed_request(self): + api = admission.Api("token") + headers = {"X-RateLimit-Remaining": "0", "X-RateLimit-Limit": "1000"} + error = urllib.error.HTTPError("url", 403, "Forbidden", headers, None) + with mock.patch.object(admission.urllib.request, "urlopen", side_effect=error), \ + self.assertRaises(urllib.error.HTTPError): + api.get("runs") + self.assertEqual(api.usage(0), " API calls this check: 1, rate limit left: 0 of 1000") + + class WaitTest(unittest.TestCase): def run_wait(self, results, limit=5, wait_minutes=60, poll_seconds=120): results = iter(results)