This is an automated email from the ASF dual-hosted git repository. potiuk pushed a commit to branch fork-ci-jobs-smaller-pages in repository https://gitbox.apache.org/repos/asf/airflow.git
commit 7b80c0850c8cf6763d66a4205354f0e0fd720357 Author: Jarek Potiuk <[email protected]> AuthorDate: Mon Nov 2 21:09:28 2026 +0100 Retry fork CI job listings in smaller pages GitHub answers HTTP 502 on every attempt when it lists 100 jobs per page of some large fork runs that were re-run, while smaller pages of the same run list fine. The fork CI monitor then never read the re-run's jobs, so job statuses of the failed first attempt stayed red on the pull request after the re-run passed, as on #74696. Generated-by: Claude Opus 5 --- .../src/airflow_breeze/utils/fork_ci_github.py | 59 ++++++++++++++---- .../src/airflow_breeze/utils/fork_ci_reconcile.py | 12 +--- dev/breeze/tests/test_fork_ci_github.py | 69 ++++++++++++++++++++++ dev/breeze/tests/test_fork_ci_reconcile.py | 15 ++--- 4 files changed, 126 insertions(+), 29 deletions(-) diff --git a/dev/breeze/src/airflow_breeze/utils/fork_ci_github.py b/dev/breeze/src/airflow_breeze/utils/fork_ci_github.py index bc763c7e546..9d84bc89f1c 100644 --- a/dev/breeze/src/airflow_breeze/utils/fork_ci_github.py +++ b/dev/breeze/src/airflow_breeze/utils/fork_ci_github.py @@ -45,6 +45,11 @@ SERVER_ERROR = re.compile(r"\bHTTP 5\d\d\b|connection reset by peer|unexpected E # Network failures before the request reached GitHub, so repeating it is safe for any call. CONNECTION_ERROR = re.compile(r"TLS handshake timeout|dial tcp") SERVER_ERROR_WAIT_SECONDS = 10 +# GitHub answers HTTP 502 on every attempt when it lists 100 jobs per page of some runs with a few +# hundred jobs, re-run more than once, while it lists smaller pages of the same run. A smaller size is +# tried once the previous one keeps failing with server errors. +JOB_PAGE_SIZES = (100, 30, 15, 5) +JOB_PAGE_SIZE_SERVER_ERROR_ATTEMPTS = 2 # A hung `gh` would otherwise hold the job until its timeout-minutes. GH_TIMEOUT_SECONDS = 120 @@ -69,6 +74,7 @@ class GitHubClient: *, allow_error_output: bool = False, retry_server_errors: bool = True, + max_server_error_attempts: int = MAX_ATTEMPTS, ) -> Any: """ :param allow_error_output: return the JSON output of a failed call when it has any. ``gh api @@ -77,6 +83,8 @@ class GitHubClient: :param retry_server_errors: repeat a call that failed with a server error. Off for a call that may have taken effect anyway and must not be repeated, such as creating a comment. A call that failed to connect is repeated either way. + :param max_server_error_attempts: how many times the call is made before giving up on server + errors; rate limits and connection errors are retried up to ``MAX_ATTEMPTS`` times either way. """ payload = json.dumps(body) if body is not None else None rate_limited = False @@ -98,12 +106,10 @@ class GitHubClient: rate_limited = any(marker in result.stderr.lower() for marker in RATE_LIMIT_MARKERS) self.rate_limited += rate_limited connection_error = bool(CONNECTION_ERROR.search(result.stderr)) - if ( - rate_limited - or connection_error - or (retry_server_errors and SERVER_ERROR.search(result.stderr)) - ): - if attempt == MAX_ATTEMPTS - 1: + server_error = not rate_limited and not connection_error and SERVER_ERROR.search(result.stderr) + if rate_limited or connection_error or (retry_server_errors and server_error): + last_attempt = max_server_error_attempts if server_error else MAX_ATTEMPTS + if attempt >= last_attempt - 1: break reason = ( "rate limit" @@ -123,9 +129,9 @@ class GitHubClient: pass raise GitHubError(f"gh {' '.join(args)} failed: {result.stderr.strip()}") if rate_limited: - raise GitHubError(f"gh {' '.join(args)} still rate limited after {MAX_ATTEMPTS} attempts") + raise GitHubError(f"gh {' '.join(args)} still rate limited after {attempt + 1} attempts") raise GitHubError( - f"gh {' '.join(args)} still failing after {MAX_ATTEMPTS} attempts: {result.stderr.strip()}" + f"gh {' '.join(args)} still failing after {attempt + 1} attempts: {result.stderr.strip()}" ) def graphql( @@ -149,20 +155,53 @@ class GitHubClient: console_print(f"[warning]Partial GraphQL result: {response['errors']}[/]") return response["data"] - def rest_get(self, path: str, *, paginate: bool = False, items_key: str | None = None) -> Any: + def rest_get( + self, + path: str, + *, + paginate: bool = False, + items_key: str | None = None, + max_server_error_attempts: int = MAX_ATTEMPTS, + ) -> Any: """ GET a REST path. :param paginate: follow every page and return the concatenated items. :param items_key: for endpoints whose pages are objects, the key holding the items (``workflow_runs``, ``jobs``, ``files``); pages are plain lists otherwise. + :param max_server_error_attempts: how many times the call is made before giving up on server + errors. """ args = ["api", path, *(["--paginate", "--slurp"] if paginate else [])] - result = self._gh(args, None, "rest") + result = self._gh(args, None, "rest", max_server_error_attempts=max_server_error_attempts) if paginate: return [item for page in result for item in (page[items_key] if items_key else page)] return result + def get_run_jobs(self, repo: str, run_id: int) -> list[dict[str, Any]]: + """The jobs of a workflow run's latest attempt, in smaller pages while GitHub fails to list them.""" + + def get_jobs(page_size: int, max_server_error_attempts: int) -> list[dict[str, Any]]: + return self.rest_get( + f"repos/{repo}/actions/runs/{run_id}/jobs?filter=latest&per_page={page_size}", + paginate=True, + items_key="jobs", + max_server_error_attempts=max_server_error_attempts, + ) + + *larger, smallest = JOB_PAGE_SIZES + for page_size in larger: + try: + return get_jobs(page_size, JOB_PAGE_SIZE_SERVER_ERROR_ATTEMPTS) + except GitHubError as e: + if not SERVER_ERROR.search(str(e)): + raise + console_print( + f"[warning]Listing the jobs of {repo} run {run_id} {page_size} per page keeps " + "failing, trying smaller pages[/]" + ) + return get_jobs(smallest, MAX_ATTEMPTS) + def rest_get_pages(self, path: str) -> list[Any]: """GET every page of a paginated REST path, keeping page-level fields such as ``total_count``.""" return self._gh(["api", path, "--paginate", "--slurp"], None, "rest") diff --git a/dev/breeze/src/airflow_breeze/utils/fork_ci_reconcile.py b/dev/breeze/src/airflow_breeze/utils/fork_ci_reconcile.py index 80dd7242a3e..d642b573e5f 100644 --- a/dev/breeze/src/airflow_breeze/utils/fork_ci_reconcile.py +++ b/dev/breeze/src/airflow_breeze/utils/fork_ci_reconcile.py @@ -1395,11 +1395,7 @@ class JobStatusReporter: if self.remaining == 0: self._defer(pr, fork) return None - jobs = self._client.rest_get( - f"repos/{pr.head_repo}/actions/runs/{fork.run_id}/jobs?filter=latest&per_page=100", - paginate=True, - items_key="jobs", - ) + jobs = self._client.get_run_jobs(pr.head_repo, fork.run_id) # By the case-folded context, as GitHub compares them. current = { status["context"].casefold(): ( @@ -1552,11 +1548,7 @@ class BypassFinder: def _get_jobs(self, run: WorkflowRun) -> list[dict[str, Any]]: if run.id not in self._jobs: - self._jobs[run.id] = self._client.rest_get( - f"repos/{self._repo}/actions/runs/{run.id}/jobs?filter=latest&per_page=100", - paginate=True, - items_key="jobs", - ) + self._jobs[run.id] = self._client.get_run_jobs(self._repo, run.id) return self._jobs[run.id] def _did_run_project_ci(self, run: WorkflowRun) -> bool: diff --git a/dev/breeze/tests/test_fork_ci_github.py b/dev/breeze/tests/test_fork_ci_github.py index 25ae3f105c6..ab497dd3b34 100644 --- a/dev/breeze/tests/test_fork_ci_github.py +++ b/dev/breeze/tests/test_fork_ci_github.py @@ -238,3 +238,72 @@ def test_set_status_context(mock_run, kwargs, context): GitHubClient().set_status(1, "abc", "success", "https://x", "desc", **kwargs) assert json.loads(mock_run.call_args.kwargs["input"])["context"] == context + + +def _jobs_page(*names: str) -> str: + return json.dumps([{"total_count": len(names), "jobs": [{"name": name} for name in names]}]) + + +def _jobs_path(page_size: int) -> str: + return f"repos/someone/airflow/actions/runs/7/jobs?filter=latest&per_page={page_size}" + + +BAD_GATEWAY = "gh: Server Error (HTTP 502)" + + [email protected]("airflow_breeze.utils.fork_ci_github.time.sleep", autospec=True) [email protected]("airflow_breeze.utils.fork_ci_github.subprocess.run", autospec=True) +def test_lists_run_jobs_in_smaller_pages_while_github_fails_to_list_them(mock_run, mock_sleep): + mock_run.side_effect = [ + *[_completed(1, stderr=BAD_GATEWAY)] * 4, + _completed(0, stdout=_jobs_page("Tests", "Docs")), + ] + + jobs = GitHubClient().get_run_jobs("someone/airflow", 7) + + assert jobs == [{"name": "Tests"}, {"name": "Docs"}] + assert [c.args[0][2] for c in mock_run.call_args_list] == [ + _jobs_path(100), + _jobs_path(100), + _jobs_path(30), + _jobs_path(30), + _jobs_path(15), + ] + + [email protected]("airflow_breeze.utils.fork_ci_github.time.sleep", autospec=True) [email protected]("airflow_breeze.utils.fork_ci_github.subprocess.run", autospec=True) +def test_gives_up_listing_run_jobs_when_the_smallest_pages_keep_failing(mock_run, mock_sleep): + mock_run.return_value = _completed(1, stderr=BAD_GATEWAY) + + with pytest.raises(GitHubError, match=r"per_page=5 --paginate --slurp still failing after 4 attempts"): + GitHubClient().get_run_jobs("someone/airflow", 7) + assert [c.args[0][2] for c in mock_run.call_args_list] == [ + *[_jobs_path(100)] * 2, + *[_jobs_path(30)] * 2, + *[_jobs_path(15)] * 2, + *[_jobs_path(5)] * 4, + ] + + [email protected]("airflow_breeze.utils.fork_ci_github.time.sleep", autospec=True) [email protected]("airflow_breeze.utils.fork_ci_github.subprocess.run", autospec=True) +def test_listing_run_jobs_retries_a_rate_limit_as_often_as_any_call(mock_run, mock_sleep): + mock_run.side_effect = [ + *[_completed(1, stderr="API rate limit exceeded")] * 3, + _completed(0, stdout=_jobs_page("Tests")), + ] + + assert GitHubClient().get_run_jobs("someone/airflow", 7) == [{"name": "Tests"}] + assert [c.args[0][2] for c in mock_run.call_args_list] == [_jobs_path(100)] * 4 + assert [c.args[0] for c in mock_sleep.call_args_list] == [30, 60, 120] + + [email protected]("airflow_breeze.utils.fork_ci_github.time.sleep", autospec=True) [email protected]("airflow_breeze.utils.fork_ci_github.subprocess.run", autospec=True) +def test_does_not_shrink_run_job_pages_for_errors_other_than_server_errors(mock_run, mock_sleep): + mock_run.return_value = _completed(1, stderr="gh: Not Found (HTTP 404)") + + with pytest.raises(GitHubError, match="HTTP 404"): + GitHubClient().get_run_jobs("someone/airflow", 7) + mock_run.assert_called_once() diff --git a/dev/breeze/tests/test_fork_ci_reconcile.py b/dev/breeze/tests/test_fork_ci_reconcile.py index 417a08540af..41c6b970794 100644 --- a/dev/breeze/tests/test_fork_ci_reconcile.py +++ b/dev/breeze/tests/test_fork_ci_reconcile.py @@ -933,8 +933,6 @@ def _finder( return [{"total_count": count, "workflow_runs": event_runs}] def rest_get(path, paginate=False, items_key=None): - if path.endswith("jobs?filter=latest&per_page=100"): - return list(jobs) if "/compare/" in path: assert not paginate, "the compare API returns files only on its first page" return {"files": [{"filename": name} for name in compare_files]} @@ -944,6 +942,7 @@ def _finder( client.rest_get_pages.side_effect = rest_get_pages client.rest_get.side_effect = rest_get + client.get_run_jobs.return_value = list(jobs) return BypassFinder(client, repo=REPO, now=NOW, base_triggers=BASE_TRIGGERS, is_gated_base=is_gated_base) @@ -1130,9 +1129,7 @@ def test_finder_reports_a_run_another_pull_request_may_own(client, other_pulls): def test_finder_catches_a_workflow_moved_away(client): finder = _finder(client, [_run(7)], jobs=TAMPERED_JOBS) client.rest_get.side_effect = lambda path, paginate=False, items_key=None: ( - TAMPERED_JOBS - if path.endswith("jobs?filter=latest&per_page=100") - else [{"number": 42, "closed_at": None}] + [{"number": 42, "closed_at": None}] if "/pulls?head=" in path else {"files": [{"filename": "docs/x.yml", "previous_filename": ".github/workflows/x.yml"}]} ) @@ -2087,13 +2084,12 @@ def test_run_reconcile_deletes_gated_runs(mock_classify, client, tmp_path, state def rest_get(path, paginate=False, items_key=None): if path.endswith("/actions/workflows/ci-amd.yml"): return {"state": "active"} - if path.endswith("jobs?filter=latest&per_page=100"): - return GATED_JOBS if "/compare/" in path: return {"files": [{"filename": "README.md"}]} raise AssertionError(path) client.rest_get.side_effect = rest_get + client.get_run_jobs.return_value = GATED_JOBS (tmp_path / "ci-amd.yml").write_text("on: [pull_request]\n") run_reconcile( @@ -2413,13 +2409,14 @@ COMPLETED_RUN = ForkObservation( def _reporter_client(client, jobs, current=()): def rest_get(path, paginate=False, items_key=None): - if path.startswith("repos/someone/airflow/actions/runs/1/jobs"): - return jobs if path.startswith(f"repos/{REPO}/commits/abc123/status"): return list(current) raise AssertionError(path) client.rest_get.side_effect = rest_get + client.get_run_jobs.side_effect = lambda repo, run_id: ( + jobs if (repo, run_id) == ("someone/airflow", 1) else pytest.fail(f"{repo} run {run_id}") + ) return client
