Skip to content

fix(append): keep final S2Error reachable through max-attempts envelope - #415

Closed
detail-app[bot] wants to merge 1 commit into
mainfrom
detail/bug-fix/fix-append-keep-final-s2error-reachable-through-ma-fb71c3
Closed

detail-app[bot] wants to merge 1 commit into
mainfrom
detail/bug-fix/fix-append-keep-final-s2error-reachable-through-ma-fb71c3

Conversation

@detail-app

@detail-app detail-app Bot commented Sep 30, 2026

Copy link
Copy Markdown
Contributor

Detail bug report: View on Detail

Closes #414

Bug

AppendSession's max-attempts-exhausted terminal error wrapped the final attempt's *S2Error with %v instead of %w at two sites (handleSessionStartError and handleSessionError in s2/append_session.go). This dropped the *S2Error from the errors.Unwrap chain, so:

  • All-definite case: HasNoSideEffects returned false even when every attempt was provably side-effect free (e.g. rate_limited), breaching its godoc contract ("no append took effect across every attempt of the operation") — a conservative misclassification that would suppress a safe auto-retry.
  • Indefinite-then-definite case: withPriorUncertainty silently no-op'd, so the terminal error was not wrapped in *AppendIndefiniteFailureError and the final *S2Error was unreachable for diagnostics.

The bug was latent until commit 0e72870 ("preserve uncertainty across append retries") started consuming the envelope via HasNoSideEffects/errors.As.

Fix

Wrap the final attempt error with %w (Go 1.23 supports multi-%w) at both max-attempts call sites:

fmt.Errorf("max attempts (%d) exhausted, last error: %w: %w", maxAttempts, err, ErrMaxAttemptsExhausted)

The rendered error string is unchanged; only the unwrap chain now includes the final *S2Error, so errors.As(&s2Err) and HasNoSideEffects behave per contract.

Testing

  • New TestAppendSession_MaxAttemptsExhaustedUncertainty and TestAppendSession_StartMaxAttemptsExhaustedUncertainty cover both max-attempts sites, asserting errors.As(&s2Err) reachability + pointer identity, errors.Is(ErrMaxAttemptsExhausted), the HasNoSideEffects contract for both all-definite and indefinite-then-definite traces, the *AppendIndefiniteFailureError wrap, and that the rendered envelope string is unchanged. These fail before the fix and pass after; they fill the regression-test gap that let the bug ship (the existing uncertainty test bypassed the fmt.Errorf path).
  • Unit tests, go vet, gofmt, golangci-lint (v2.1.6), and go build ./... all pass; the new and existing append-session/uncertainty tests pass under -race.
  • End-to-end: ran the s2-package lite integration suite (the CI test_cmd skip pattern) and the internal/faulttest race harness against a real S2 Lite server (s2 CLI v0.43.1, s2 lite); all TestAppendSession_*, TestReadSession_*, and fault-tests pass under -race.
  • Could not run: the real-S2 cloud integration suite (task test:integration) — it requires S2_ACCESS_TOKEN/S2_BASIN_ENDPOINT secrets not available here, so those tests skip with "S2_ACCESS_TOKEN not set". The lite-server runs above exercise the same append-session code paths end-to-end.

Automatic Fixes PRs can be configured here.

@detail-app
detail-app Bot requested a review from a team as a code owner September 30, 2026 05:03
@greptile-apps

greptile-apps Bot commented Sep 30, 2026

Copy link
Copy Markdown

PR author is not in the allowed authors list.

infiniteregrets added a commit that referenced this pull request Oct 16, 2026
Evaluated the open Detail bug reports against latest `main`. Eight are
real and fixed here; one (#345) is not a bug.

## Fixed

| Issue | Fix |
|---|---|
| #416 | `ReadSession.Next` drains records the reader already buffered
before surfacing a fatal error, so they are not skipped by a resume from
`NextReadPosition`. Once `errorCh` yields, `Next` reads only `recordsCh`
until it closes. |
| #365 | The read retry loop resets the failure streak whenever an
attempt delivered records, including reconnect-advised and
server-draining handovers. |
| #414 | The max-attempts error wraps the final attempt error with `%w`,
so `HasNoSideEffects` and `withPriorUncertainty` can reach the
`*S2Error`. The message text is unchanged. |
| #403 | `APPEND_CONDITION_FAILED` (412) is classified as side-effect
free (non-retryable), so after an uncertain attempt it is wrapped in
`AppendIndefiniteFailureError`. |
| #405 | `checkTimeouts` reads the head and claims `currentSession`
together under `inflightMu`, so an ACK that pops the head can't be
followed by a stale timeout tearing down the session.
`handleSessionError` is split into claim and
`handleClaimedSessionError`. Lock order is `inflightMu → sessionMu`;
nothing takes them in reverse. |
| #362 | `FrameReader` returns `io.ErrUnexpectedEOF` when the body ends
mid-frame. Read sessions treat that as retryable instead of a clean end.
|
| #363 | When the last session on a poisoned pool entry closes its body,
the entry's idle connections are closed. Streaming transports also get a
90s `IdleConnTimeout` as a backstop. |
| #404 | When `ClientOptions.HTTPClient` uses an `*http.Transport`,
streaming requests use its `TLSClientConfig` and `Proxy`. HTTP(S)
proxies are tunnelled with CONNECT (with Basic auth); SOCKS5 goes
through `x/net/proxy`. Clients without an `HTTPClient` behave exactly as
before. |

## Also fixed: reconnect during failure handling

While testing,
`TestAppendSession_NoSideEffectsNoRetryWithNonIdempotentSentPipeline`
turned out to be flaky on `main` (6/200 under `-race`), and the #405
refactor shifted timing so it failed more often (46/200). The cause is
real: after a session failure is claimed, the pump could open a new
session and resend inflight appends before the failure handler decided
to fail them. Under `AppendRetryPolicyNoSideEffects`, that could resend
a non-idempotent append. Claimed failures are now counted in
`failuresPending`, and `getSession` won't reconnect while one is pending
or the pump is cancelled (checked under `sessionMu`). The test now
passes 0/900.

## Not a bug: #345

When batch A's checkpoint write fails and batch B's later succeeds, the
checkpoint covering A and B is correct, because A's records were
processed successfully. The suggested fix (set `Acked` only after
`cache.Set` succeeds) would make things worse: combined with the
`sync.Once` ack guard, A would never be retried and would block the
checkpoint permanently.

## Relation to the Detail PRs

This covers #406, #407, #408, #415 and #417. #408 is not a safe
approach: it drops the ACK of a timed-out head, which reports a
successful append as a timeout.

## Testing

Following `AGENTS.md`, tests cover only behavior that spans components
or does I/O; the one-line rules (#403, #365) are explained in comments
instead. Each test below fails on `main`:

- `TestReadSessionDeliversBufferedRecordsBeforeFatalError` (#416)
- `TestAppendSession_MaxAttemptsKeepsFinalErrorReachable` (#414)
- `TestFrameReaderReportsTruncatedFrameAsUnexpectedEOF` (#362)
- `TestSpreadTransportClosesPoisonedEntryWhenLastSessionEnds` (#363)
- `TestStreamingClientUsesUserTLSConfig`,
`TestStreamingClientUsesUserProxy` (#404)

The #405 race window can't be hit deterministically, so it has no test.
`go test -race ./...` passes. Integration tests against S2 were not run.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@infiniteregrets

Copy link
Copy Markdown
Member

Superseded by #423, which fixes this issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Detail Bug] Append session: max-attempts errors hide underlying S2Error, breaking HasNoSideEffects and uncertainty preservation

1 participant