Generated client honors WithMaxRetries and retries once after a 401 refresh

Merged
JE
2mo ago
+1163-1121

Card: https://app.basecamp.com/2914079/buckets/48521764/card_tables/cards/10258105817

Surfaced by reviewers on #136 as pre-existing and spec-wide, so declined there.

WithMaxRetries was ignored for generated operations. The generated client runs its own retry loop and initGeneratedClient never passed WithRetryConfig, so all ~100 generated ops ran on the generated defaults (3 retries, 1s base delay) regardless of what the caller configured. MaxRetries and BaseDelay are now passed through; idempotency still decides whether a retry happens at all, so nothing changes for a non-idempotent op beyond the point below.

No 401 refresh path for generated operations. AuthManager refreshes 5 minutes ahead of expiry, but a mid-flight 401 was surfaced as-is where the hand-written Get/Post/form paths refresh and send once more. The generated client gains an AuthRefresher hook (WithAuthRefresher), wired to the same refreshCredentials the hand-written paths use. A 401 gets one refresh and one more send; a second 401 surfaces. Non-idempotent ops get this too, since a 401 means the server did nothing with the request.

To make the refresh path cover every op, non-idempotent operations now go through doWithRetry as well, keeping their single attempt. Bodies handed over as an io.Reader (*WithBody variants; three hand-written wrappers use them with bytes.NewReader) are rewound before each build, or held in memory when not seekable, so a resend carries the body the first send drained. That also closes a latent gap for idempotent *WithBody retries, which previously rebuilt from an exhausted reader.

Tests cover: retry count honored (0 and 2), 401 → refresh → retry once for a read and for a mutation, reader bodies sent whole both times, a second 401 surfaces after one refresh, and no retry when nothing can refresh. Each fails against main.

Not included: the code: 204 DELETE sweep mentioned on the card.

TMPDIR=/tmp/t make check green (MVP gate + conformance, 173/173).


Summary by cubic

Fixes the generated client ignoring WithMaxRetries and WithBaseDelay, so generated operations now honor the caller's retry settings instead of the default three retries. A mid-flight 401 now refreshes credentials once and resends the request, surfacing an error if the second attempt 401s again.

  • Non-idempotent operations get this refresh too, since a 401 means the server took no action; all operations run through the same retry path.
  • The resend shares the first pass's retry budget instead of starting fresh, and the delay ceiling follows the configured BaseDelay.
  • Bodies passed as an io.Reader are rewound before each send — buffered in memory when not seekable, kept from early closure when they are — so resends carry the full body.
  • Tests cover the retry count (0 and 2), refresh-then-resend for a read and a mutation, reader bodies sent whole twice, a second 401 surfacing, no retry when nothing can refresh, and the non-seekable in-memory body branch.