From 6c0ce3672e7f3f2662eb5a8c94ee99a262dd5e65 Mon Sep 17 00:00:00 2001 From: Jeffrey Wall <265409180+jeffwall-curlewlabs@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:13:26 -0700 Subject: [PATCH] Retry a request gh sent and got no answer to gh_api retries a busy or unreachable API, but the only status-less failure it counted as unreachable was gh's "error connecting to" line, which gh prints for a failed name lookup alone. Every other request left unanswered - a dial or TLS handshake that timed out, a reset connection, a proxy that refused - prints Go's error for it instead, such as Get "https://api.github.com/...": dial tcp 20.29.134.17:443: i/o timeout and that failed on the first attempt. On a clock host it happened to the mark read, so the tick sent nothing and exited 1, and its slot waited for the next tick. Go's error opens with the method and the quoted URL, so that shape is what counts as unanswered now, rather than a list of causes Go words many ways. A cause no retry clears, such as a refused certificate, costs only the ladder's waits before the same failure. A request that applied before its answer was lost was already retried on a timeout or a 5xx, and its callers cover that: a mark claim reads back whose blob the mark holds, a ledger create or delete takes its 422 as done, and a second dispatch of a slot is one more delivery, which the concurrency group and slot action run once. The version goes to 0.2.2. --- README.md | 2 +- pyproject.toml | 2 +- src/github_cron_trigger/github.py | 14 +++++++++++--- tests/test_steps.py | 31 +++++++++++++++++++++++++++++-- 4 files changed, 42 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index e95f070..65c8b92 100644 --- a/README.md +++ b/README.md @@ -362,7 +362,7 @@ Any host that can reach `api.github.com` will do, and more than one is better. Install a release, which puts `github-cron-trigger` on PATH: ```sh -pip install "github-cron-trigger @ git+https://github.com/curlewlabs-com/github-cron-trigger@v0.2.1" +pip install "github-cron-trigger @ git+https://github.com/curlewlabs-com/github-cron-trigger@v0.2.2" ``` Or check out a release tag and run it as `PYTHONPATH=src python3 -m diff --git a/pyproject.toml b/pyproject.toml index bc054a0..cccacf8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "flit_core.buildapi" [project] name = "github-cron-trigger" -version = "0.2.1" +version = "0.2.2" description = "Run each scheduled slot of a GitHub Actions workflow on time and exactly once, with GitHub's schedule as the backstop." readme = "README.md" license = "MIT" diff --git a/src/github_cron_trigger/github.py b/src/github_cron_trigger/github.py index e4eca82..9e4506a 100644 --- a/src/github_cron_trigger/github.py +++ b/src/github_cron_trigger/github.py @@ -28,8 +28,15 @@ r"^gh: (?P.*) \(HTTP (?P\d{3})\)$", re.MULTILINE ) -# The failure that carries no status: `gh` never reached the host. +# The failures that carry no status, because `gh` got no answer. A failed name +# lookup prints the first. Any other request left unanswered - a dial or TLS +# handshake that timed out, a reset connection, a proxy that refused - prints +# Go's error for it, which opens with the method and the quoted URL. The shape +# is matched rather than a list of causes, which Go words many ways; a cause no +# retry clears, such as a refused certificate, costs only the ladder's waits +# before the same failure. _CONNECT_FAILURE = "error connecting to " +_NO_ANSWER = re.compile(r'^(?:Get|Head|Post|Put|Patch|Delete) "[^"]*": ') # GitHub answers a secondary rate limit with a 403 whose message says so. Every # other 403 is a refusal, so the message is the only way to tell them apart. @@ -55,7 +62,8 @@ def classify(stderr: str) -> tuple[int | None, str]: """The HTTP status and message of a failed `gh api` call, from its stderr. The status is None when `gh` reported no HTTP exchange (a connection - failure); the message is then the whole stderr. + failure, or a failure before any request); the message is then the whole + stderr. """ text = stderr.strip() match = _GH_API_ERROR.search(text) @@ -67,7 +75,7 @@ def classify(stderr: str) -> tuple[int | None, str]: def transient(status: int | None, message: str) -> bool: """Whether a failure is worth retrying: the API was busy or unreachable.""" if status is None: - return message.startswith(_CONNECT_FAILURE) + return message.startswith(_CONNECT_FAILURE) or bool(_NO_ANSWER.match(message)) if status >= 500 or status == 429: return True return status == 403 and _SECONDARY_RATE_LIMIT in message.lower() diff --git a/tests/test_steps.py b/tests/test_steps.py index ba8898a..3b86de9 100644 --- a/tests/test_steps.py +++ b/tests/test_steps.py @@ -511,8 +511,10 @@ class GhFailureClassificationTest(unittest.TestCase): on a transient error turns a gateway blip into a red run and, for a record step, a slot the other delivery then does again. The 404 and 422 lines are `gh api` output captured live against a repository's refs API; the - secondary-rate-limit and connection lines are the forms GitHub and `gh` - print for those failures. + secondary-rate-limit and name-lookup lines are the forms GitHub and `gh` + print for those failures. The unanswered-request lines are `gh api` output + too: the timeouts from a clock host's log, with the repository renamed, and + the proxy line from pointing HTTPS_PROXY at a closed port. """ def test_refusals_are_not_retried(self) -> None: @@ -521,6 +523,9 @@ def test_refusals_are_not_retried(self) -> None: ("gh: Reference already exists (HTTP 422)", 422), ("gh: Reference does not exist (HTTP 422)", 422), ("gh: Resource not accessible by integration (HTTP 403)", 403), + # No status, but no network either: a call gh refused before + # sending must not read as an unanswered one. + ("unknown flag: --paginatee\n\nUsage: gh api [flags]", None), ): with self.subTest(stderr=stderr): self.assertEqual(classify(stderr)[0], status) @@ -535,6 +540,28 @@ def test_busy_and_unreachable_are_retried(self) -> None: " minutes before you try again. (HTTP 403)" ), "error connecting to api.github.com\ncheck your internet connection", + # Requests gh sent and got no answer to. Unretried, one dropped + # connection on the mark read cost a whole tick, since a tick + # with no marks sends nothing. + ( + 'Get "https://api.github.com/repos/owner/name/git/matching-refs/' + 'github-cron-trigger-clock/?per_page=100": dial tcp' + " 20.29.134.17:443: i/o timeout" + ), + ( + 'Get "https://api.github.com/repos/owner/name/git/matching-refs/' + 'github-cron-trigger-clock/?per_page=100": net/http: TLS' + " handshake timeout" + ), + ( + 'Delete "https://api.github.com/repos/owner/name/git/refs/' + 'github-cron-trigger-clock/backup.yml/0_1_x_x_x/20260928T0100Z":' + " dial tcp 20.29.134.17:443: i/o timeout" + ), + ( + 'Post "https://api.github.com/graphql": proxyconnect tcp: dial tcp' + " 127.0.0.1:9: connect: connection refused" + ), ): with self.subTest(stderr=stderr): self.assertTrue(transient(*classify(stderr)))