Task · SFL-0054

tests: pin the untested legs of the s3/gcs checkpoint retry classifier (raw 5xx, OSError family, non-Exception)

Status
To Do
Labels
followup, phase-1
Milestone
Test-coverage backlog
Updated
2026-08-14

Description

What

_is_transient in both object-storage checkpoint stores classifies which errors the bounded retry added by #44 will retry. Three of its four decision legs have no test.

src/sf2loki/state/s3_store.py:112-131:

def _is_transient(exc: BaseException) -> bool:
    if not isinstance(exc, Exception):          # 120  -> 121 UNCOVERED
        return False
    if _error_code(exc) in _TRANSIENT_CODES:    # 122  -> 123 covered
        return True
    status = _status_code(exc)
    if status is not None and status >= 500:    # 125  -> 126 UNCOVERED
        return True
    # TCP resets / connection drops surface as bare OSError-family exceptions
    # with no botocore response shape at all.
    if isinstance(exc, TimeoutError | ConnectionError | OSError):  # 129 -> 130 UNCOVERED
        return True
    return False

src/sf2loki/state/gcs_store.py:70-86 is the same shape minus the error-code leg: line 78 (non-Exception guard) and line 85 (OSError family) are uncovered; the status >= 500 leg at gcs_store.py:80-81 is covered.

Measured coverage over tests/state + tests/test_statecmd.py:

Name                             Stmts   Miss  Cover   Missing
src/sf2loki/state/gcs_store.py     140      8    94%   78, 85, 163-167, 310
src/sf2loki/state/s3_store.py      170     15    91%   76, 89, 121, 126, 130, 208-215, 336, 355

Why the legs never execute: every exception double in the test suite is FakeClientError(code, status) (tests/state/test_s3_store.py:29-37), FakeGcsError(status) (tests/state/test_gcs_store.py:29), or their test_statecmd.py equivalents, and the complete set of values raised anywhere is NoSuchKey/404, PreconditionFailed/412, SlowDown/503, InternalError/500. Both s3 5xx doubles carry a code that is already in _TRANSIENT_CODES (s3_store.py:97-109), so _is_transient returns at s3_store.py:123 and the HTTP-status fallback at 125-126 is never reached. No test in the repository raises an OSError-family exception into either store. _is_transient has no direct unit test.

The three uncovered legs are live and correct today — verified by calling the predicate directly:

input s3_store._is_transient gcs_store._is_transient
code SomethingNew, HTTP 500 True (via :126) n/a
code SomethingNew, HTTP 400 False n/a
ConnectionResetError True (via :130) True (via :85)
TimeoutError True (via :130) True (via :85)
asyncio.CancelledError False (via :121) False (via :78)

So the work here is regression pins for behaviour that already works, not a bug fix.

Why it matters

The retry exists because a transient object-store error on a checkpoint commit used to crash the daemon (#44): the exception propagates out of commit_many (s3_store.py:306, gcs_store.py:262) through the pipeline consumer and kills the process, dropping every gRPC stream and re-authing all orgs on restart. #44’s acceptance bar was literally “503s twice then succeeds; a 412 still raises StateStoreConflictError”, which is exactly what tests/state/test_s3_store.py:407, 434, 461, 480 and tests/state/test_gcs_store.py:411, 442 assert — so the code-agnostic legs were never pinned.

Consequences of the gap:

Exposure is bounded: both stores are opt-in extras (sf2loki[s3], sf2loki[gcs] — pyproject.toml:29-30) and the file store is the default backend, so this is a silent-regression window rather than a present defect.

Proposed approach

Add table-driven tests in tests/state/test_s3_store.py and tests/state/test_gcs_store.py. No new dependency and no extras install needed — the existing in-memory fakes and injected client_factory cover it.

  1. Direct predicate tests (cheapest, pins all legs including the guard). Parametrize over sf2loki.state.s3_store._is_transient and sf2loki.state.gcs_store._is_transient:
    • s3: FakeClientError("SlowDown", 503) -> True; FakeClientError("SomethingNew", 500) -> True; FakeClientError("SomethingNew", 502) -> True; FakeClientError("AccessDenied", 403) -> False; FakeClientError("SomethingNew", 400) -> False; ConnectionResetError("reset") -> True; TimeoutError() -> True; OSError("broken pipe") -> True; StateStoreConflictError("cas") -> False; asyncio.CancelledError() -> False.
    • gcs: FakeGcsError(503) -> True; FakeGcsError(404) -> False; FakeGcsError(412) -> False; ConnectionResetError("reset") -> True; TimeoutError() -> True; StateStoreConflictError("cas") -> False; asyncio.CancelledError() -> False.
  2. Behavioural tests through the store (pins that the classification is actually wired into _retry_transient), modelled on test_commit_survives_two_transient_errors_then_succeeds (tests/state/test_s3_store.py:407) — monkeypatch _MAX_ATTEMPTS/_WAIT_MIN/_WAIT_MAX the same way so they stay sub-millisecond:
    • s3 put_object raises ConnectionResetError once then delegates to the real fake -> commit completes, load round-trips the value, call count is 2.
    • s3 put_object raises FakeClientError("SomethingNew", 500) once then succeeds -> same assertions (this is the only way to reach s3_store.py:126).
    • gcs upload raises ConnectionResetError once then succeeds -> same assertions.
    • Mirror at least the ConnectionResetError case on the load path (get_object / download_metadata).
  3. Negative controls. Keep test_precondition_conflict_is_not_retried (tests/state/test_s3_store.py:480, and the gcs equivalent) untouched, and add a non-retryable behavioural control: put_object/upload raising a 403-shaped error must surface on the first attempt with exactly one call — proving the widened tests did not turn the classifier into “retry everything”.
  4. Assert exact attempt counts, never just “eventually succeeded” — an attempt-count assertion is what fails if a leg silently stops being retryable.

Open question to verify during implementation, do not assume: aiobotocore may wrap transport failures in botocore.exceptions.EndpointConnectionError / ConnectionClosedError, which are BotoCoreError subclasses and (unlike aiohttp’s ClientOSError) may not be OSError subclasses at all — in which case s3_store.py:130 is not the leg a real TCP reset lands on for the S3 backend, and _is_transient needs the botocore wrapper shapes added. botocore is not installed in the dev venv (it ships only with the s3 extra), so this was not checkable here. Check the MRO against the installed aiobotocore>=2.21; if the wrappers are not OSError subclasses, file a follow-up for the classifier rather than widening scope here, and leave the comment at s3_store.py:127-129 corrected to match reality.


Imported from GitHub issue #138 on 2026-08-14, when this repo migrated from GitHub Issues to Backlog.md. The original issue has been deleted; its verbatim body, labels and comments are preserved in archive/issues-dump.json (jq '.[] | select(.number == 138)' archive/issues-dump.json).

Filed from the 2026-07-30 full-repo audit (11 finder lanes + adversarial verification per finding).

Acceptance Criteria

Definition of Done

References

View the source file on GitHub