Skip to content

Commit 00bdd53

Browse files
authored
[ci] Failure bot: fixed auto-retry on transient errors
The auto retry tool previously treated all ERROR log lines as fatal failures. This blocked retries for transient infrastructure issues such as network drops. This change introduces a classification for failure markers: strict markers signal real test failures, while generic markers are ignored when a known transient crash is detected. This allows the system to correctly proceed with retries for flaky builds.
1 parent 6a06036 commit 00bdd53

2 files changed

Lines changed: 90 additions & 15 deletions

File tree

.github/actions/bot-ci-failure/analyze_failure.py

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,23 @@
66
from google import genai
77
from google.genai import types
88

9-
# Keywords that indicate an automated test failure (as opposed to
10-
# QA-only / commit-message-only failures).
11-
TEST_FAILURE_MARKERS = (
9+
# Strict markers that unequivocally indicate a failing test or assertion.
10+
STRICT_TEST_FAILURE_MARKERS = (
1211
"FAIL:",
13-
"ERROR:",
1412
"FAILED (",
15-
"Traceback (most recent call last):",
1613
"AssertionError",
1714
)
1815

16+
# Generic markers that could be from a test failure (e.g., TypeError)
17+
# OR from a transient infrastructure crash.
18+
GENERIC_TEST_FAILURE_MARKERS = (
19+
"ERROR:",
20+
"Traceback (most recent call last):",
21+
)
22+
23+
# Combined for functions that need to extract blocks of failed tests
24+
TEST_FAILURE_MARKERS = STRICT_TEST_FAILURE_MARKERS + GENERIC_TEST_FAILURE_MARKERS
25+
1926
# Patterns that indicate transient / infrastructure failures which are
2027
# not caused by the contributor's code.
2128
TRANSIENT_FAILURE_MARKERS = (
@@ -149,10 +156,25 @@ def process_error_logs(content):
149156
continue
150157
seen_bodies.add(body_key)
151158
total_unique_jobs += 1
152-
job_has_test_failure = any(m in body for m in TEST_FAILURE_MARKERS)
153-
if _is_transient_failure(body) and not job_has_test_failure:
159+
is_transient = _is_transient_failure(body)
160+
# 1. Strict markers (e.g., "FAIL:") ALWAYS mean a real test broke.
161+
has_strict_failure = any(m in body for m in STRICT_TEST_FAILURE_MARKERS)
162+
# 2. Generic markers (e.g., "Traceback") could be a code bug OR an infrastructure crash.
163+
has_generic_failure = any(m in body for m in GENERIC_TEST_FAILURE_MARKERS)
164+
if has_strict_failure:
165+
# Genuine test failures (AssertionError, FAIL:) always block auto-retry.
166+
job_has_test_failure = True
167+
elif is_transient:
168+
# If we detected a known transient error (like a network drop), we assume
169+
# the generic "ERROR:" and "Traceback" strings belong to that crash.
170+
# We safely forgive them to allow the auto-retry to trigger.
171+
job_has_test_failure = False
172+
else:
173+
# There was no transient error. Therefore, if we found generic failures
174+
# (like a SyntaxError or TypeError in the code), it is a real test failure.
175+
job_has_test_failure = has_generic_failure
176+
if is_transient and not job_has_test_failure:
154177
transient_jobs += 1
155-
# Detect real test failures and keep only the failing parts.
156178
if job_has_test_failure:
157179
tests_failed = True
158180
body = _extract_failed_tests(body)
@@ -368,6 +390,9 @@ def main():
368390
issue" and mention the CI has been restarted automatically if applicable.
369391
- For Coveralls failures specifically, mention it is a known flaky
370392
service and not actionable by the contributor.
393+
- If there are ALSO real test failures (like AssertionErrors) in the logs,
394+
tell the contributor to fix the real test failures first and push a new commit.
395+
Do NOT tell them the CI restarted automatically if real test failures exist.
371396
372397
5. **Build/Infrastructure/Other** (missing dependencies, Docker errors,
373398
setup failures that are NOT transient)

.github/actions/bot-ci-failure/test_analyze_failure.py

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -259,15 +259,55 @@ def test_single_block_no_job_headers(self):
259259
self.assertFalse(tests_failed)
260260
self.assertFalse(transient)
261261

262-
def test_transient_only_true_when_all_transient(self):
262+
def test_pure_transient_error_allows_retry(self):
263+
"""Ensure a purely transient error correctly flags for a retry."""
263264
content = (
264-
"===== JOB 100 =====\n"
265-
"marionette.errors.MarionetteException: connection lost\n"
266-
"===== JOB 200 =====\n"
267-
"NS_ERROR_ABORT in browser\n"
265+
"===== JOB 1 =====\n"
266+
"Setting up Selenium...\n"
267+
"selenium.common.exceptions.InvalidSessionIdException: Message: Session is not active\n"
268268
)
269-
_, _, transient = process_error_logs(content)
270-
self.assertTrue(transient)
269+
text, tests_failed, transient_only = process_error_logs(content)
270+
self.assertFalse(tests_failed)
271+
self.assertTrue(transient_only)
272+
273+
def test_transient_forgives_generic_traceback(self):
274+
"""Ensure transient crashes with generic tracebacks don't falsely trigger test failures."""
275+
content = (
276+
"===== JOB 2 =====\n"
277+
"Traceback (most recent call last):\n"
278+
" File 'urllib3/connectionpool.py', line 703\n"
279+
"ERROR: Could not install packages due to an OSError: HTTPSConnectionPool\n"
280+
"Network is unreachable\n"
281+
)
282+
text, tests_failed, transient_only = process_error_logs(content)
283+
self.assertFalse(tests_failed)
284+
self.assertTrue(transient_only)
285+
286+
def test_strict_failure_blocks_transient_retry(self):
287+
"""Ensure a strict failure (AssertionError) blocks retry even if a transient error exists."""
288+
content = (
289+
"===== JOB 3 =====\n"
290+
"FAIL: test_user_login\n"
291+
"AssertionError: True is not False\n"
292+
"...\n"
293+
"Connection reset by peer\n"
294+
)
295+
text, tests_failed, transient_only = process_error_logs(content)
296+
self.assertTrue(tests_failed)
297+
self.assertFalse(transient_only)
298+
299+
def test_pure_generic_bug_blocks_retry(self):
300+
"""Ensure a generic traceback without a transient error is flagged as a real code bug."""
301+
content = (
302+
"===== JOB 4 =====\n"
303+
"Traceback (most recent call last):\n"
304+
" File 'app/models.py', line 45, in save\n"
305+
"TypeError: 'NoneType' object is not subscriptable\n"
306+
"ERROR: Process exited with code 1\n"
307+
)
308+
text, tests_failed, transient_only = process_error_logs(content)
309+
self.assertTrue(tests_failed)
310+
self.assertFalse(transient_only)
271311

272312
def test_transient_only_false_when_mixed(self):
273313
content = (
@@ -288,6 +328,16 @@ def test_coveralls_only_is_transient(self):
288328
_, _, transient = process_error_logs(content)
289329
self.assertTrue(transient)
290330

331+
def test_transient_marker_containing_error_keyword(self):
332+
content = (
333+
"===== JOB 100 =====\n"
334+
"ERROR: Could not install packages due to an OSError: HTTPSConnectionPool\n"
335+
"Network is unreachable\n"
336+
)
337+
text, tests_failed, transient_only = process_error_logs(content)
338+
self.assertTrue(transient_only)
339+
self.assertFalse(tests_failed)
340+
291341

292342
class TestNormalizeForDedup(unittest.TestCase):
293343
"""Tests for _normalize_for_dedup."""

0 commit comments

Comments
 (0)