test_jsondecodeerror_retryable.py
1 """Regression guard for #14782: json.JSONDecodeError must not be classified 2 as a local validation error by the main agent loop. 3 4 `json.JSONDecodeError` inherits from `ValueError`. The agent loop's 5 non-retryable classifier at run_agent.py treats `ValueError` / `TypeError` 6 as local programming bugs and skips retry. Without an explicit carve-out, 7 a transient provider hiccup (malformed response body, truncated stream, 8 routing-layer corruption) that surfaces as a JSONDecodeError would bypass 9 the retry path and fail the turn immediately. 10 11 This test mirrors the exact predicate shape used in run_agent.py so that 12 any future refactor of that predicate must preserve the invariant: 13 14 JSONDecodeError → NOT local validation error (retryable) 15 UnicodeEncodeError → NOT local validation error (surrogate path) 16 bare ValueError → IS local validation error (programming bug) 17 bare TypeError → IS local validation error (programming bug) 18 """ 19 from __future__ import annotations 20 21 import json 22 23 24 def _mirror_agent_predicate(err: BaseException) -> bool: 25 """Exact shape of run_agent.py's is_local_validation_error check. 26 27 Kept in lock-step with the source. If you change one, change both — 28 or, better, refactor the check into a shared helper and have both 29 sites import it. 30 """ 31 return ( 32 isinstance(err, (ValueError, TypeError)) 33 and not isinstance(err, (UnicodeEncodeError, json.JSONDecodeError)) 34 ) 35 36 37 class TestJSONDecodeErrorIsRetryable: 38 39 def test_json_decode_error_is_not_local_validation(self): 40 """Provider returning malformed JSON surfaces as JSONDecodeError — 41 must be treated as transient so the retry path runs.""" 42 try: 43 json.loads("{not valid json") 44 except json.JSONDecodeError as exc: 45 assert not _mirror_agent_predicate(exc), ( 46 "json.JSONDecodeError must be excluded from the " 47 "ValueError/TypeError local-validation classification." 48 ) 49 else: 50 raise AssertionError("json.loads should have raised") 51 52 def test_unicode_encode_error_is_not_local_validation(self): 53 """Existing carve-out — surrogate sanitization handles this separately.""" 54 try: 55 "\ud800".encode("utf-8") 56 except UnicodeEncodeError as exc: 57 assert not _mirror_agent_predicate(exc) 58 else: 59 raise AssertionError("encoding lone surrogate should raise") 60 61 def test_bare_value_error_is_local_validation(self): 62 """Programming bugs that raise bare ValueError must still be 63 classified as local validation errors (non-retryable).""" 64 assert _mirror_agent_predicate(ValueError("bad arg")) 65 66 def test_bare_type_error_is_local_validation(self): 67 assert _mirror_agent_predicate(TypeError("wrong type")) 68 69 70 class TestAgentLoopSourceStillHasCarveOut: 71 """Belt-and-suspenders: the production source must actually include 72 the json.JSONDecodeError carve-out. Protects against an accidental 73 revert that happens to leave the test file intact.""" 74 75 def test_run_agent_excludes_jsondecodeerror_from_local_validation(self): 76 import run_agent 77 import inspect 78 src = inspect.getsource(run_agent) 79 # The predicate we care about must reference json.JSONDecodeError 80 # in its exclusion tuple. We check for the specific co-occurrence 81 # rather than the literal string so harmless reformatting doesn't 82 # break us. 83 assert "is_local_validation_error" in src 84 assert "JSONDecodeError" in src, ( 85 "run_agent.py must carve out json.JSONDecodeError from the " 86 "is_local_validation_error classification — see #14782." 87 )