Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
14 changes: 11 additions & 3 deletions src/github_cron_trigger/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,15 @@
r"^gh: (?P<message>.*) \(HTTP (?P<status>\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.
Expand All @@ -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)
Expand All @@ -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()
Expand Down
31 changes: 29 additions & 2 deletions tests/test_steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 <endpoint> [flags]", None),
):
with self.subTest(stderr=stderr):
self.assertEqual(classify(stderr)[0], status)
Expand All @@ -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)))
Expand Down