This is an automated email from the ASF dual-hosted git repository.

shahar1 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 8693c0854fe Retry fork CI job listings in smaller pages (#76327)
8693c0854fe is described below

commit 8693c0854fe24b94c5ff9f9dc0a675c1315cd165
Author: Jarek Potiuk <[email protected]>
AuthorDate: Tue Nov 3 13:57:21 2026 +0100

    Retry fork CI job listings in smaller pages (#76327)
    
    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
 
 

Reply via email to