Skip to content

Commit 5519913

Browse files
authored
Merge pull request #6 from CCExtractor/fix/wait-error-vs-outcome
Separate a failed run from a wait that could not finish
2 parents 358e1e9 + 9e52c42 commit 5519913

5 files changed

Lines changed: 79 additions & 17 deletions

File tree

AGENTS.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,8 @@ sp run artifacts <run> # binary, coredump, stdout, outputs
6969
sp queue # what is running right now
7070
sp run progress <run> # live status of one run
7171
sp run wait <run> [<run> ...] # block until they finish; 0 all passed,
72-
# 1 something failed, 9 timed out
72+
# 1 a run failed, 9 timed out,
73+
# 10 the wait itself broke
7374
sp run ls --pr <n> # every run for one pull request
7475
sp run ls --created-after 2026-08-09T00:00:00Z # recent activity
7576
```
@@ -133,9 +134,13 @@ sp run ls --limit 5 | jq -r '.data[] | "\(.run_id) \(.platform) \(.status)
133134
```
134135

135136
Exit codes to branch on: `0` ok, `3` unreachable, `4` not found, `5` validation,
136-
`6` auth, `7` rate limited, `8` conflict, `9` `run wait` timed out. **`2` means
137-
you got the command wrong** — an unknown flag, a missing argument, a value out
138-
of range — so treat it as a bug in your invocation, never as an outcome.
137+
`6` auth, `7` rate limited, `8` conflict, `9` `run wait` timed out, `10` `run
138+
wait` broke before it could finish. Under `run wait`, `1` means a run failed or
139+
was canceled and nothing else does, so `1` is a verdict about the code while
140+
`9`/`10` say only that the platform misbehaved — block on the first, retry the
141+
others. **`2` means you got the command wrong** — an unknown flag, a missing
142+
argument, a value out of range — so treat it as a bug in your invocation, never
143+
as an outcome.
139144

140145
Progress spinners, retry notices, and colour go to **stderr** and are suppressed
141146
when stdout is not a terminal, so JSON on stdout is always parseable.

README.md

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -277,12 +277,26 @@ Scripts and agents can branch on the exit status:
277277
| 7 | rate limited |
278278
| 8 | conflict (e.g. deleting a test that has results) |
279279
| 9 | `run wait` gave up before every run finished |
280+
| 10 | `run wait` could not finish waiting (API error with no code of its own) |
280281

281-
`run wait` additionally exits 1 when a run it waited for failed or was
282-
canceled. Note what is *not* in this table: **2**, which Click returns for any
283-
usage error — an unknown flag, a missing argument, an out-of-range value. Never
284-
give 2 a meaning of your own, or a mistyped flag becomes indistinguishable from
285-
a real outcome.
282+
Under `run wait`, **1 means a run you waited for failed or was canceled** — a
283+
verdict about the code, and the only exit code that is one. Everything that
284+
went wrong with the wait itself lands on 3–8 or 10. The distinction is what
285+
lets a merge gate block on a bad branch and retry on a bad afternoon:
286+
287+
```bash
288+
sp run wait $ID
289+
case $? in
290+
0) merge ;;
291+
1) block ;; # the branch is bad
292+
9|10) retry ;; # the platform is; nothing was learned about the branch
293+
esac
294+
```
295+
296+
Note what is *not* in this table: **2**, which Click returns for any usage
297+
error — an unknown flag, a missing argument, an out-of-range value. Never give
298+
2 a meaning of your own, or a mistyped flag becomes indistinguishable from a
299+
real outcome.
286300

287301
## Development
288302

sp_cli/commands/run.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,11 @@
1212
from sp_cli.constants import (ARTIFACT_TYPES, CANCEL_REASON_MIN_LENGTH,
1313
COMMIT_SHA_LENGTH, ERROR_GROUP_BY,
1414
ERROR_SEVERITIES, ERROR_TYPES, EXIT_TIMEOUT,
15-
INFRA_ERROR_TYPES, LOG_CONTAINS_MAX_LENGTH,
16-
LOG_LEVELS, LOG_SOURCES, MAX_OFFSET,
17-
MAX_PAGE_LIMIT, MAX_REGRESSION_TEST_IDS,
18-
PLATFORMS, PR_SCAN_DEFAULT, PR_SCAN_MAX,
15+
EXIT_WAIT_ABORTED, INFRA_ERROR_TYPES,
16+
LOG_CONTAINS_MAX_LENGTH, LOG_LEVELS, LOG_SOURCES,
17+
MAX_OFFSET, MAX_PAGE_LIMIT,
18+
MAX_REGRESSION_TEST_IDS, PLATFORMS,
19+
PR_SCAN_DEFAULT, PR_SCAN_MAX,
1920
RUN_PENDING_STATUSES, RUN_STATUSES,
2021
RUN_UNSUCCESSFUL_STATUSES, SAMPLE_STATUSES,
2122
WAIT_INTERVAL_DEFAULT, WAIT_INTERVAL_MAX,
@@ -778,7 +779,9 @@ def run_wait(ctx: click.Context, run_ids: Tuple[int, ...], interval: int,
778779
779780
Exits 0 only if every run finished successfully; a failed or canceled run
780781
exits 1 and a timeout exits 9, which lets a script gate on the result
781-
without parsing the output.
782+
without parsing the output. If the wait itself cannot be completed the exit
783+
code describes that instead: 3 through 8 for an API error that maps to one,
784+
10 for anything else. Nothing but a run's own verdict exits 1.
782785
783786
A run whose status is `pass` exits 0 even if it recorded results for only a
784787
fraction of its samples, because the API derives that status from the rows
@@ -797,7 +800,12 @@ def run_wait(ctx: click.Context, run_ids: Tuple[int, ...], interval: int,
797800
record = client.get(f'/runs/{run_id}')
798801
except ApiError as error:
799802
render_error(error, output)
800-
raise SystemExit(error.exit_code)
803+
# An error with no code of its own exits 1 everywhere else, but
804+
# this command has already given 1 a meaning: a run you waited
805+
# for failed. Collapsing both into 1 tells a merge gate to block
806+
# the branch when the platform merely returned a 500.
807+
code = error.exit_code
808+
raise SystemExit(EXIT_WAIT_ABORTED if code == 1 else code)
801809
status = record.get('status')
802810
if status not in RUN_PENDING_STATUSES:
803811
finished[run_id] = record

sp_cli/constants.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
21
"""Enumerations accepted by the platform API, mirrored so the CLI can reject bad input locally.
32
43
Each tuple matches a validator in the merged ``mod_api`` blueprint. Keeping them
@@ -38,6 +37,14 @@
3837
#: and Click, so the first free code is 9.
3938
EXIT_TIMEOUT = 9
4039

40+
#: ``sp run wait`` exits with this when the wait itself could not be completed:
41+
#: an API failure that has no more specific code of its own (a 500, a malformed
42+
#: response). Distinct from 1, which this command spends on "a run you waited
43+
#: for failed" -- a merge gate has to tell a bad branch from a platform hiccup,
44+
#: because the first should block and the second should be retried. Errors that
45+
#: do map to a code of their own (3 through 8) keep it.
46+
EXIT_WAIT_ABORTED = 10
47+
4148
#: ``sp run wait`` polling bounds, in seconds.
4249
WAIT_INTERVAL_DEFAULT = 30
4350
WAIT_INTERVAL_MIN = 5

tests/test_cli.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from click.testing import CliRunner
88

99
from sp_cli.client import ApiError
10-
from sp_cli.constants import EXIT_TIMEOUT
10+
from sp_cli.constants import EXIT_TIMEOUT, EXIT_WAIT_ABORTED
1111
from sp_cli.main import cli
1212
from tests import SESSION_SANDBOX # noqa: F401
1313

@@ -1675,3 +1675,31 @@ def test_wait_surfaces_api_errors(self, mock_get):
16751675
result = self.runner.invoke(cli, ['run', 'wait', '9476'])
16761676

16771677
self.assertNotEqual(result.exit_code, 0)
1678+
1679+
@mock.patch('sp_cli.client.ApiClient.get')
1680+
def test_a_broken_wait_is_distinguishable_from_a_failed_run(self, mock_get):
1681+
"""1 means a run failed, never that the wait itself broke.
1682+
1683+
A merge gate blocks on 1. If an API failure with no code of its own also
1684+
exited 1, a 500 from the platform would read as "this branch is bad" and
1685+
block a branch nothing is wrong with -- and retrying, the right response
1686+
to a 500, is the one thing the script would not do.
1687+
"""
1688+
mock_get.side_effect = ApiError('server_error', 'boom', status=500)
1689+
broke = self.runner.invoke(cli, ['run', 'wait', '9476'])
1690+
1691+
mock_get.side_effect = None
1692+
mock_get.return_value = {'run_id': 9476, 'status': 'fail'}
1693+
failed = self.runner.invoke(cli, ['run', 'wait', '9476'])
1694+
1695+
self.assertEqual(broke.exit_code, EXIT_WAIT_ABORTED)
1696+
self.assertEqual(failed.exit_code, 1)
1697+
self.assertNotEqual(broke.exit_code, failed.exit_code)
1698+
1699+
@mock.patch('sp_cli.client.ApiClient.get')
1700+
def test_api_errors_that_map_to_a_code_keep_it(self, mock_get):
1701+
"""Only the codeless errors are remapped; 3-8 still mean what they mean."""
1702+
for status, expected in ((404, 4), (401, 6), (429, 7), (409, 8)):
1703+
mock_get.side_effect = ApiError('err', 'nope', status=status)
1704+
result = self.runner.invoke(cli, ['run', 'wait', '9476'])
1705+
self.assertEqual(result.exit_code, expected, f'HTTP {status}')

0 commit comments

Comments
 (0)