Skip to content

Commit 874f518

Browse files
aiwarelifeclaude
andcommitted
fix: retry gh network calls with backoff and self-heal failure markers
Transient GitHub/Azure download drops (EOF, SSL_ERROR_SYSCALL) were flipping whole sync runs to failure. Retry the network calls (git fetch, gh api artifact list, gh run download) with exponential backoff + jitter, and clear a stale failed/<id>.json once its artifact syncs cleanly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent dde1fd7 commit 874f518

1 file changed

Lines changed: 52 additions & 14 deletions

File tree

scripts/sync_collection_reports.py

Lines changed: 52 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@
99
import hashlib
1010
import json
1111
import os
12+
import random
1213
import re
1314
import shutil
1415
import subprocess
1516
import sys
1617
import tempfile
18+
import time
1719
from pathlib import Path
1820
from typing import Any
1921

@@ -30,25 +32,47 @@
3032
LEGACY_ADDED_PATTERN = re.compile(r"^- 本次新增插件:(\d+)$", re.MULTILINE)
3133
LEGACY_PLUGIN_PATTERN = re.compile(r"^### \[([^]]+)]\((https://github\.com/[^)]+)\)$", re.MULTILINE)
3234

35+
# Transient GitHub/Azure network drops (EOF, SSL_ERROR_SYSCALL, connection reset)
36+
# were flipping whole runs to failure. Retry only the network calls, with backoff.
37+
NETWORK_RETRIES = 3
38+
NETWORK_RETRY_BACKOFF = 1.0
39+
3340

3441
class ArchiveError(RuntimeError):
3542
"""A report could not be safely archived."""
3643

3744

3845
def command(
39-
args: list[str], *, cwd: Path = ROOT, check: bool = True
46+
args: list[str],
47+
*,
48+
cwd: Path = ROOT,
49+
check: bool = True,
50+
retries: int = 0,
51+
backoff: float = NETWORK_RETRY_BACKOFF,
4052
) -> subprocess.CompletedProcess[str]:
41-
result = subprocess.run(
42-
args,
43-
cwd=cwd,
44-
check=False,
45-
capture_output=True,
46-
text=True,
47-
)
48-
if check and result.returncode != 0:
53+
attempt = 0
54+
while True:
55+
result = subprocess.run(
56+
args,
57+
cwd=cwd,
58+
check=False,
59+
capture_output=True,
60+
text=True,
61+
)
62+
if result.returncode == 0 or not check:
63+
return result
4964
detail = result.stderr.strip() or result.stdout.strip() or "command failed"
50-
raise ArchiveError(f"{args[0]} failed: {detail}")
51-
return result
65+
if attempt >= retries:
66+
raise ArchiveError(f"{args[0]} failed: {detail}")
67+
# Exponential backoff with jitter, so a transient blip does not burn the run.
68+
delay = backoff * (2**attempt) + random.uniform(0, backoff)
69+
print(
70+
f"{args[0]} failed (attempt {attempt + 1}/{retries + 1}), "
71+
f"retrying in {delay:.1f}s: {detail.splitlines()[0][:200]}",
72+
file=sys.stderr,
73+
)
74+
time.sleep(delay)
75+
attempt += 1
5276

5377

5478
def list_artifacts(gh: str, repository: str) -> list[dict[str, Any]]:
@@ -59,7 +83,8 @@ def list_artifacts(gh: str, repository: str) -> list[dict[str, Any]]:
5983
gh,
6084
"api",
6185
f"repos/{repository}/actions/artifacts?per_page=100&page={page}",
62-
]
86+
],
87+
retries=NETWORK_RETRIES,
6388
)
6489
payload = json.loads(result.stdout)
6590
batch = payload.get("artifacts", [])
@@ -129,6 +154,11 @@ def append_manifest(path: Path, record: dict[str, Any]) -> None:
129154
os.fsync(handle.fileno())
130155

131156

157+
def failure_record_path(archive_root: Path, artifact: dict[str, Any]) -> Path:
158+
"""Location of an artifact's last-error marker (written on failure, cleared on success)."""
159+
return archive_root / "failed" / f"{artifact.get('id', 'unknown')}.json"
160+
161+
132162
def publication_commit(repo_root: Path, run_id: str) -> str | None:
133163
marker = f"<!-- topic-sync:{run_id} -->"
134164
result = command(
@@ -398,7 +428,11 @@ def sync_reports(
398428
archive_root: Path,
399429
dry_run: bool = False,
400430
) -> dict[str, int]:
401-
command(["git", "fetch", "--quiet", "origin", "main"], cwd=repo_root)
431+
command(
432+
["git", "fetch", "--quiet", "origin", "main"],
433+
cwd=repo_root,
434+
retries=NETWORK_RETRIES,
435+
)
402436
artifacts = select_report_artifacts(list_artifacts(gh, repository))
403437
manifest_records = load_manifest(archive_root / "manifest.jsonl")
404438
counts = {"archived": 0, "duplicate": 0, "pending": 0, "would_archive": 0}
@@ -421,6 +455,7 @@ def sync_reports(
421455
directory,
422456
],
423457
cwd=repo_root,
458+
retries=NETWORK_RETRIES,
424459
)
425460
report_json = next(Path(directory).rglob("report.json"), None)
426461
if report_json is not None:
@@ -447,6 +482,9 @@ def sync_reports(
447482
dry_run=dry_run,
448483
)
449484
counts[outcome] += 1
485+
# The artifact synced cleanly this round; drop any stale failure marker.
486+
if not dry_run:
487+
failure_record_path(archive_root, artifact).unlink(missing_ok=True)
450488
except (ArchiveError, json.JSONDecodeError, OSError) as exc:
451489
failures.append(f"{artifact.get('name')}: {exc}")
452490
failure_record = {
@@ -457,7 +495,7 @@ def sync_reports(
457495
"run_id": run_id,
458496
}
459497
atomic_write(
460-
archive_root / "failed" / f"{artifact.get('id', 'unknown')}.json",
498+
failure_record_path(archive_root, artifact),
461499
(
462500
json.dumps(failure_record, ensure_ascii=False, indent=2, sort_keys=True)
463501
+ "\n"

0 commit comments

Comments
 (0)