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)))