Repository navigation
Conversation
Why: `fetch_issues.py` ran `gh` once and turned any non-zero exit into a `SystemExit`, so a single transient `HTTP 502` aborted the whole dashboard run. Because this fetch is the first of six steps, the operator had to restart the pipeline by hand. `run_gh` now retries the failures that can clear on their own (5xx, gateway timeouts, connection resets) with exponential backoff, and still fails fast on the ones that cannot: an auth error, a bad repo slug, or an unknown flag never fixes itself. The default cache also moves off a hardcoded `/tmp`. A sandboxed run is granted its own scratch directory and cannot write `/tmp` at all, so the first fetch step failed before it ever reached the API. The default is now `<tempfile.gettempdir()>/tracker-stats-cache`, which honours `$TMPDIR` on POSIX and `%TEMP%` on Windows; an explicit `TRACKER_STATS_CACHE` still wins. The default output path moves with it, for the same reason. Closes apache#1744. Generated-by: WorkBuddy AI (DeepSeek-V4.1-Flash)
Kaap10
left a comment
There was a problem hiding this comment.
Thanks for addressing #1744, @sharpca! Moving the cache defaults out of hardcoded /tmp to tempfile.gettempdir() solves the sandbox write restriction nicely, and introducing exponential backoff on 5xx transient errors makes the pipeline much more resilient. The test suite in test_fetch_retry.py is clean and covers the retry states well.
A few findings from review to address before this can land:
1. SKILL.md frontmatter drift (surface_hash & measured_tokens)
File: plugins/magpie-security/skills/tracker-stats-dashboard/SKILL.md (lines 19–21)
Because prose in Step 2 was modified (lines 210–212), the surface_hash and measured_tokens in the frontmatter are out of date:
- In this repository,
prekrunsskill-surface-hashandcheck-measured-tokens, which will fail CI once the workflows run on this PR. - Fix: Running
prek run --all-files(or updating the hash with the helper scripts) will automatically recalculate and update these frontmatter stamps.
2. Unhandled subprocess.TimeoutExpired in run_gh
File: tools/security-tracker-stats-dashboard/fetch_issues.py (lines 70–75)
In fetch_issues.py:
for attempt in range(1, attempts + 1):
r = subprocess.run(args, capture_output=True, text=True, timeout=timeout)
if r.returncode == 0:
return r_TRANSIENT_FAILURE regex includes "timed out", which matches if gh itself outputs a timeout to stderr. However, if the process hits the 300-second subprocess limit, subprocess.run raises subprocess.TimeoutExpired rather than returning a CompletedProcess with non-zero exit code. Because this exception is unhandled, it will terminate execution immediately without retrying.
Suggested fix: Catch subprocess.TimeoutExpired so it participates in the retry loop:
for attempt in range(1, attempts + 1):
try:
r = subprocess.run(args, capture_output=True, text=True, timeout=timeout)
except subprocess.TimeoutExpired as exc:
if attempt == attempts:
raise SystemExit(f"gh timed out after {timeout}s: {exc}")
delay = base_delay * 2 ** (attempt - 1)
print(
f"gh timed out (attempt {attempt}/{attempts}), retrying in {delay:g}s",
file=sys.stderr,
)
time.sleep(delay)
continue
if r.returncode == 0:
return r3. Path consistency in run.sh
File: tools/security-tracker-stats-dashboard/run.sh (line 58)
echo "done: ${TRACKER_STATS_OUT:-${TMPDIR:-/tmp}/airflow_s_monthly.html}"render.py resolves the path via Python's tempfile.gettempdir(), which resolves to %TEMP% on Windows, whereas bash's ${TMPDIR:-/tmp} defaults to /tmp. On non-POSIX environments, the completion message will point to /tmp while the file actually lives under %TEMP%. Not a blocker for POSIX hosts, but worth keeping aligned.
| ) -> subprocess.CompletedProcess: | ||
| """Run `gh`, retrying transient failures with exponential backoff. | ||
|
|
||
| Raises `SystemExit` with the last stderr once the attempts are exhausted, or |
There was a problem hiding this comment.
_TRANSIENT_FAILURE regex includes "timed out", which matches if gh itself outputs a timeout to stderr. However, if the command hits the 300s subprocess limit, subprocess.run raises subprocess.TimeoutExpired instead of returning a completed process.
Consider wrapping subprocess.run to catch TimeoutExpired and treat it as transient so it can also be retried:
| Raises `SystemExit` with the last stderr once the attempts are exhausted, or | |
| try: | |
| r = subprocess.run(args, capture_output=True, text=True, timeout=timeout) | |
| except subprocess.TimeoutExpired as exc: | |
| if attempt == attempts: | |
| raise SystemExit(f"gh timed out after {timeout}s: {exc}") | |
| delay = base_delay * 2 ** (attempt - 1) | |
| print( | |
| f"gh timed out (attempt {attempt}/{attempts}), retrying in {delay:g}s", | |
| file=sys.stderr, | |
| ) | |
| time.sleep(delay) | |
| continue |
| `tracker_stats_cache` value from the step 1 config (else | ||
| `${TRACKER_STATS_CACHE:-/tmp/tracker-stats-cache}`, the fetch scripts' default). | ||
| `${TRACKER_STATS_CACHE:-<platform temp dir>/tracker-stats-cache}`, the | ||
| fetch scripts' default — `$TMPDIR` on POSIX, `%TEMP%` on Windows). |
There was a problem hiding this comment.
Because prose in Step 2 was modified here, the frontmatter surface_hash and measured_tokens (lines 19–21) are now out of date.
Running prek run --all-files (or python tools/dev/check-measured-tokens.py --fix) will recalculate and update these stamps so the CI skill-surface-hash hook passes.
| "${PY_CMD[@]}" "$HERE/render.py" | ||
|
|
||
| echo "done: ${TRACKER_STATS_OUT:-/tmp/airflow_s_monthly.html}" | ||
| echo "done: ${TRACKER_STATS_OUT:-${TMPDIR:-/tmp}/airflow_s_monthly.html}" |
There was a problem hiding this comment.
Small consistency nit: render.py resolves the path via Python's tempfile.gettempdir(), which maps to %TEMP% on Windows, whereas bash's ${TMPDIR:-/tmp} will fallback to /tmp.
Not a blocker on POSIX, but worth keeping in mind or letting render.py print the exact final path it wrote to.
Summary
fetch_issues.pyretried nothing: a single transientHTTP 502from the APIbecame a
SystemExit, and since this fetch is the first of six steps theoperator had to restart the whole dashboard run by hand.
run_ghnow retriesthe failures that can clear on their own (5xx, gateway timeouts, connection
resets) with exponential backoff, and still fails fast on an auth error, a bad
repo slug, or an unknown flag.
/tmp. A sandboxed runis granted its own scratch directory and cannot write
/tmpat all, so thefirst fetch step failed before it ever reached the API. Both defaults now
derive from
tempfile.gettempdir()($TMPDIRon POSIX,%TEMP%on Windows);TRACKER_STATS_CACHEandTRACKER_STATS_OUTstill win when set.run.sh, the skill prose, the adopter template, and the step-2 evalsystem prompt follow the new default.
Type of change
tools/*/withpyproject.toml)docs/,README.md,CONTRIBUTING.md)tracker-stats-dashboardSKILL.mdrestates the cache default; no behaviour change, so no new fixture.
Test plan
uv run pytest/ruff check/mypypassespytest: 149 passed (142 existing + 7 new intests/test_fetch_retry.py)ruff check .andruff format --check .: cleanmypy: no issues in 6 source filestests/test_fetch_retry.pycover transient-status detection,retry-then-succeed with a doubling backoff, give-up after the attempt
limit, no retry on a 401, and both branches of the cache default.
RFC-AI-0004 compliance
paths (
/tmp/tracker-stats-cache,/tmp/airflow_s_monthly.html); no newhost access or network reach is introduced.
Linked issues
Closes #1744
Notes for reviewers
TRACKER_STATS_OUTwas not named in the issue, but it hardcoded/tmpfor thesame reason and would have kept the run failing after the cache was fixed.
Happy to split it out if you would rather keep this PR to the cache alone.
report.mdfiles keepCache directory: /tmp/...: on aPOSIX host without
TMPDIR,tempfile.gettempdir()is/tmp, so that outputis still exactly what the new default produces.
secondary-rate-limit is deliberately not retried — it needs the operator to
read the message, not a shorter backoff.
Generative AI disclosure
This change was authored with an AI coding agent (WorkBuddy AI, DeepSeek-V4.1-Flash)
under human review. The commit carries the
Generated-by:trailer; thecontributor has reviewed the diff and owns the change.