Go + Python: honor per-operation retry.max as a ceiling; add parity guard - #483
Conversation
|
Series — retry-contract follow-up (branch fresh off
#481 and #483 both touch |
There was a problem hiding this comment.
Pull request overview
This PR aligns the Go and Python SDKs’ retry behavior with the repo’s per-operation retry contract by enforcing retry.max as an operation-specific ceiling (effective_attempts = min(client_cap, op_max)), and adds a cross-SDK parity guard to prevent future drift from behavior-model.json.
Tip
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.
Changes:
- Go: emit per-operation
RetryMaxfromx-basecamp-retry.maxAttemptsand clamp retry attempts byoperationIdindoWithRetry. - Python: plumb canonical PascalCase
operationIdinto the transport for GET/list/pagination and apply the same per-op ceiling in sync + async HTTP clients. - Add
scripts/check-retry-metadata-parity.pyand wire it intomake check; document the behavior inSPEC.mdand add tests validating the ceiling.
Reviewed changes
Copilot reviewed 9 out of 54 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| SPEC.md | Documents the per-operation retry ceiling semantics and observable behavior changes. |
| scripts/check-retry-metadata-parity.py | New guard: validates emitted retry metadata equals behavior-model.json and token-smoke checks runtime consumption sites. |
| Makefile | Adds check-retry-metadata-parity to make check. |
| go/templates/client.tmpl | Generates OperationMetadata.RetryMax and applies the per-op ceiling in the retry loop. |
| go/pkg/generated/client.gen.go | Regenerated Go client reflecting the new per-op retry ceiling metadata + logic. |
| go/pkg/basecamp/generated_perop_retry_test.go | Adds Go tests pinning per-op ceiling behavior against real generated metadata. |
| python/src/basecamp/_http.py | Adds operation plumb-through and applies per-op retry ceiling in sync transport retry loop. |
| python/src/basecamp/_async_http.py | Adds operation plumb-through and applies per-op retry ceiling in async transport retry loop. |
| python/src/basecamp/generated/services/_base.py | Ensures sync generated services pass operation through GET/list/pagination paths. |
| python/src/basecamp/generated/services/_async_base.py | Ensures async generated services pass operation through GET/list/pagination paths. |
| python/scripts/generate_services.py | Generator now always emits canonical operation="<OperationId>" kwarg (including GET/list/pagination). |
| python/tests/test_http.py | Adds unit + integration tests proving per-op ceilings apply (sync + async) via real generated services + bundled metadata. |
| python/src/basecamp/generated/services/account.py | Passes canonical operation IDs into transport calls for per-op ceiling. |
| python/src/basecamp/generated/services/automation.py | Passes canonical operation IDs into transport calls for per-op ceiling. |
| python/src/basecamp/generated/services/boosts.py | Passes canonical operation IDs into transport calls for per-op ceiling. |
| python/src/basecamp/generated/services/campfires.py | Passes canonical operation IDs into transport calls for per-op ceiling. |
| python/src/basecamp/generated/services/card_columns.py | Passes canonical operation IDs into transport calls for per-op ceiling. |
| python/src/basecamp/generated/services/card_steps.py | Passes canonical operation IDs into transport calls for per-op ceiling. |
| python/src/basecamp/generated/services/card_tables.py | Passes canonical operation IDs into transport calls for per-op ceiling. |
| python/src/basecamp/generated/services/cards.py | Passes canonical operation IDs into transport calls for per-op ceiling. |
| python/src/basecamp/generated/services/checkins.py | Passes canonical operation IDs into transport calls for per-op ceiling. |
| python/src/basecamp/generated/services/client_approvals.py | Passes canonical operation IDs into transport calls for per-op ceiling. |
| python/src/basecamp/generated/services/client_correspondences.py | Passes canonical operation IDs into transport calls for per-op ceiling. |
| python/src/basecamp/generated/services/client_replies.py | Passes canonical operation IDs into transport calls for per-op ceiling. |
| python/src/basecamp/generated/services/comments.py | Passes canonical operation IDs into transport calls for per-op ceiling. |
| python/src/basecamp/generated/services/documents.py | Passes canonical operation IDs into transport calls for per-op ceiling. |
| python/src/basecamp/generated/services/events.py | Passes canonical operation IDs into transport calls for per-op ceiling. |
| python/src/basecamp/generated/services/everything.py | Passes canonical operation IDs into transport calls for per-op ceiling. |
| python/src/basecamp/generated/services/forwards.py | Passes canonical operation IDs into transport calls for per-op ceiling. |
| python/src/basecamp/generated/services/gauges.py | Passes canonical operation IDs into transport calls for per-op ceiling. |
| python/src/basecamp/generated/services/hill_charts.py | Passes canonical operation IDs into transport calls for per-op ceiling. |
| python/src/basecamp/generated/services/message_boards.py | Passes canonical operation IDs into transport calls for per-op ceiling. |
| python/src/basecamp/generated/services/message_types.py | Passes canonical operation IDs into transport calls for per-op ceiling. |
| python/src/basecamp/generated/services/messages.py | Passes canonical operation IDs into transport calls for per-op ceiling. |
| python/src/basecamp/generated/services/my_assignments.py | Passes canonical operation IDs into transport calls for per-op ceiling. |
| python/src/basecamp/generated/services/my_notifications.py | Passes canonical operation IDs into transport calls for per-op ceiling. |
| python/src/basecamp/generated/services/people.py | Passes canonical operation IDs into transport calls for per-op ceiling. |
| python/src/basecamp/generated/services/projects.py | Passes canonical operation IDs into transport calls for per-op ceiling. |
| python/src/basecamp/generated/services/recordings.py | Passes canonical operation IDs into transport calls for per-op ceiling. |
| python/src/basecamp/generated/services/reports.py | Passes canonical operation IDs into transport calls for per-op ceiling. |
| python/src/basecamp/generated/services/schedules.py | Passes canonical operation IDs into transport calls for per-op ceiling. |
| python/src/basecamp/generated/services/search.py | Passes canonical operation IDs into transport calls for per-op ceiling. |
| python/src/basecamp/generated/services/subscriptions.py | Passes canonical operation IDs into transport calls for per-op ceiling. |
| python/src/basecamp/generated/services/templates.py | Passes canonical operation IDs into transport calls for per-op ceiling. |
| python/src/basecamp/generated/services/timeline.py | Passes canonical operation IDs into transport calls for per-op ceiling. |
| python/src/basecamp/generated/services/timesheets.py | Passes canonical operation IDs into transport calls for per-op ceiling. |
| python/src/basecamp/generated/services/todolist_groups.py | Passes canonical operation IDs into transport calls for per-op ceiling. |
| python/src/basecamp/generated/services/todolists.py | Passes canonical operation IDs into transport calls for per-op ceiling. |
| python/src/basecamp/generated/services/todos.py | Passes canonical operation IDs into transport calls for per-op ceiling. |
| python/src/basecamp/generated/services/todosets.py | Passes canonical operation IDs into transport calls for per-op ceiling. |
| python/src/basecamp/generated/services/tools.py | Passes canonical operation IDs into transport calls for per-op ceiling. |
| python/src/basecamp/generated/services/uploads.py | Passes canonical operation IDs into transport calls for per-op ceiling. |
| python/src/basecamp/generated/services/vaults.py | Passes canonical operation IDs into transport calls for per-op ceiling. |
| python/src/basecamp/generated/services/webhooks_service.py | Passes canonical operation IDs into transport calls for per-op ceiling. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
All reported issues were addressed across 54 files
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
8fc4d5c to
07533ae
Compare
|
Addressed the bot review:
Guard green on both criteria; Go drift/tests green. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 54 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (3)
scripts/check-retry-metadata-parity.py:56
- This script prints Unicode symbols (e.g., ✗/✓). On systems where Python’s stdout encoding is ASCII (common when locale is
C/POSIX), these prints can raiseUnicodeEncodeErrorand makemake checkfail even when the parity checks pass.
Consider forcing UTF-8 for stdout (or switching to ASCII-only output) so the guard is robust across environments.
scripts/check-retry-metadata-parity.py:215
- Criterion 2’s “token-smoke” check uses exact substring tokens that include whitespace (e.g.,
"baseDelayMs ??"). This is brittle: harmless formatting changes in the consuming file (like removing spaces) can breakmake checkeven though runtime consumption is unchanged.
Prefer whitespace-insensitive tokens (e.g., retryConfig.baseDelayMs) or regex-based matching.
go/templates/client.tmpl:579
OperationMetadatais exported and returned fromGetOperationMetadata. Adding a new exported field (RetryMax) is a source-breaking change for downstream users who constructOperationMetadatawith positional composite literals (e.g.,generated.OperationMetadata{true, false}), because Go requires updating those literals when struct fields change.
If maintaining Go source compatibility is important, consider keeping OperationMetadata’s public shape stable (e.g., store per-op retry max in a separate internal map or add a separate accessor like GetOperationRetryMax(opID)), or at minimum call out the breaking change explicitly in release notes.
// RetryMax is the per-operation maximum TOTAL attempt count from the
// x-basecamp-retry extension (0 when the operation carries no override, in
// which case the client-configured cap applies unchanged). doWithRetry uses
// it as a per-operation ceiling: effective attempts = min(client cap, RetryMax).
RetryMax int
07533ae to
7a4c329
Compare
|
Addressed the exported-API concern:
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 54 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (2)
go/pkg/basecamp/generated_perop_retry_test.go:20
- The comment claims per-operation retry max is emitted into
operationMetadata.RetryMax, but the generated client actually emits it via the separateoperationRetryMaxtable (exposed bygenerated.GetOperationRetryMax). This is misleading for future maintainers reading the tests.
// These tests pin the generated client's per-operation retry ceiling. The
// x-basecamp-retry.maxAttempts value for each operation is emitted into
// operationMetadata.RetryMax, and doWithRetry applies it as a ceiling (upper
// bound on attempts): effective attempts = min(client cap, RetryMax). This
// matches how TS/Swift/
scripts/check-retry-metadata-parity.py:25
- The docstring says Go emits the per-op max as
operationMetadata.RetryMax, but this PR actually emits it via the separateoperationRetryMaxmap ingo/pkg/generated/client.gen.go(andGetOperationRetryMax). The comment should reflect the real output so the parity guard remains understandable.
…uard
A4b2 — generated Go and Python drove their retry loops from the client-wide
cap only, ignoring the per-operation retry.max that TS/Swift/Kotlin already
honor (Python shipped it in metadata.json as dead data; Go emitted no per-op
table). Wire the per-op value in as a ceiling (upper bound on attempts):
effective_attempts = min(client_cap, op_max)
Precedence rationale: the per-op field is named `max`, so it composes with the
client cap by taking the smaller. TS/Swift/Kotlin expose no numeric client cap
and use op_max directly; because every op's max is <= the default cap of 3,
min() makes a default-or-raised Go/Python client produce exactly the per-op
attempt count (parity), while uniquely honoring a Go/Python client that lowered
its cap to 0/1 to disable/restrict retries. Observable change: a default client
sees only the 9 idempotent max:2 ops drop 3->2; a client that RAISED its cap
above 3 now sees all 187 retry-eligible ops clamped to their per-op max
(release-noted in SPEC).
- Go: emit OperationMetadata.RetryMax from x-basecamp-retry.maxAttempts
(client.tmpl) and apply the ceiling in doWithRetry by operationId.
- Python: thread the CANONICAL (PascalCase) operationId into
_request_with_retry (sync + async) for every retryable path. Crucially the
generated GET/list/pagination methods now pass operation="<OpId>" too —
previously they passed nothing or OperationInfo.operation (snake_case, not a
metadata key), so the ceiling silently never applied to the 118 GET ops.
generate_services.py now emits the operation= kwarg on GET/list/paginated
calls; _base/_async_base thread it through _request_list and all three
pagination wrappers.
A4c — add scripts/check-retry-metadata-parity.py, wired into `make check`.
Criterion 1 (static): emitted per-op retry values EQUAL behavior-model.json
across all six emitters (Python/Ruby/TS/Kotlin/Swift full tuple; Go max only —
a documented design deviation: Go consumes only max, so emitting the full inert
tuple into a Go struct would be dead data). Criterion 2 (token-smoke
classification, NOT behavioral proof): usage-form token presence/absence per
consuming source classifies TS/Swift/Kotlin=full tuple, Go/Python=max only,
Ruby=none, and catches gross removal of a consumption site (behavioral proof
lives in each SDK's retry tests).
Tests (outside pkg/generated): Go ceiling test proven to fail without the
min() application; Python sync+async ceiling tests; and INTEGRATION tests
through real generated services with the real bundled metadata.json
(projects.list -> ListProjects max:3; account.update_account_name ->
UpdateAccountName max:2), proven to fail against the snake_case-key bug (5
attempts vs 3). Parity guard self-tested against mutated values.
Follows the retry-contract program (#456/#460/#461/#476).
7a4c329 to
aee8bd9
Compare
|
Corrected the terminology so the public explanation matches the code:
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 54 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (4)
python/tests/test_http.py:505
client.close()is not guaranteed to run if the test fails before reaching it (e.g., assertion failure). Usetry/finallyso the client is always closed.
python/tests/test_http.py:525- Ensure
await client.close()runs even when the test fails early by usingtry/finally.
python/tests/test_http.py:496 client.close()is not in afinallyblock, so if the assertion fails (or an unexpected exception is raised) the underlying httpx client may not be closed, which can leak resources and cause noisy warnings in the test suite. Wrap the call intry/finallyso cleanup always happens.
This issue also appears on line 501 of the same file.
python/tests/test_http.py:516
await client.close()should be executed even if the assertion fails; otherwise the async client can be left unclosed. Wrap the body intry/finally.
This issue also appears on line 521 of the same file.
… cap The paragraph claimed TypeScript, Swift, and Kotlin "expose no numeric client-wide cap, only an on/off". True for TypeScript and Swift, which have only enableRetry. False for Kotlin: BasecampConfig.maxRetries is an Int, and BasecampHttpClient resolves the two as `opRetry?.maxRetries ?: config.maxRetries` — the per-op value overrides the client's instead of bounding it, so a Kotlin caller who lowers maxRetries to 1 still gets the operation's count. That makes Kotlin the SDK where the ceiling genuinely behaves as a floor, which is what #485 tracks. Ruby has the opposite gap: it bounds by config.max_retries alone and never enforces the per-op max. Rationale only — no behavior change.
|
Corrected the cross-SDK rationale in The paragraph said TypeScript, Swift, and Kotlin "expose no numeric client-wide cap, only an on/off". That is true for TypeScript ( val maxAttempts = opRetry?.maxRetries ?: config.maxRetriesso the per-op value overrides the client's rather than bounding it. Because every operation has a retry block, That makes Kotlin the SDK where the ceiling genuinely behaves as a floor. Ruby has the opposite gap: it bounds by I also overstated this in #485 by lumping TypeScript and Swift in with Kotlin — with no numeric cap there is nothing to override, so the finding is vacuous for those two. Correcting that issue too. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 54 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (3)
python/src/basecamp/_http.py:323
isinstance(op_max, int)will treat booleans as ints (True == 1,False == 0). If a caller supplies custommetadata(e.g., in tests or advanced usage) and accidentally setsretry.maxto a boolean, this will silently clamp retries incorrectly. Prefer a strictinttype check here.
op_max = self._metadata.get(operation, {}).get("retry", {}).get("max")
if isinstance(op_max, int) and 0 < op_max < max_attempts:
return op_max
python/src/basecamp/_async_http.py:323
isinstance(op_max, int)will treat booleans as ints (True == 1,False == 0). If a caller supplies custommetadataand accidentally setsretry.maxto a boolean, this will silently clamp retries incorrectly. Prefer a strictinttype check here.
op_max = self._metadata.get(operation, {}).get("retry", {}).get("max")
if isinstance(op_max, int) and 0 < op_max < max_attempts:
return op_max
go/templates/client.tmpl:628
- The
okreturn value here is described as "false when the operation carries no per-op override", butoperationRetryMaxis emitted with a positive max for every known operation, sookis only false for an unknown/unrecognized operationId. This comment is misleading for callers relying onoksemantics.
// GetOperationRetryMax returns the per-operation retry ceiling (maximum total
// attempts) for the given operation ID. ok is false when the operation carries
// no per-op override, in which case the client-configured cap applies unchanged.
Problem (A4b2)
Every operation carries a per-op
retry.maxinbehavior-model.json(183 ops at3, 43 at2). TypeScript, Swift, and Kotlin honor it; generated Go and Python did not — Python shipped it inmetadata.jsonas dead data, Go emitted no per-op table. So a customized Go/Python client retried the more side-effect-sensitive ops more times than the other SDKs.Fix — per-op ceiling
min(client_cap, op_max)The per-op field is named
max, so it composes with the client cap by taking the smaller (an upper bound on attempts). TypeScript and Swift expose no numeric client cap (only an on/off) and useop_maxdirectly. Kotlin does exposeBasecampConfig.maxRetries, but resolvesopRetry?.maxRetries ?: config.maxRetries— the per-op value overrides rather than bounds the client cap, so it behaves as a floor (a divergence from the ceiling contract, tracked in #485). Because every op'smax ≤the default cap of3,min()makes a default-or-raised Go/Python client produce exactly the per-op attempt count (parity), while uniquely honoring a client that lowered its cap to0/1to disable/restrict retries.operationRetryMaxmap (opId → max) fromx-basecamp-retry.maxAttempts, plus an additiveGetOperationRetryMax(operationId)accessor;doWithRetryreads it and applies the ceiling byoperationId. Kept off the exportedOperationMetadatastruct so wiring it up doesn't change that struct's shape (which would break external unkeyed composite literals) —OperationMetadatais byte-identical tomain._request_with_retry(sync + async) for every retryable path. Notably the generated GET/list/pagination methods now passoperation="<OpId>"too — previously they passed nothing or the snake_caseOperationInfo.operation(not a metadata key), so the ceiling silently never applied to the 118 GET ops.max_retries = 3): only the 9 idempotentmax:2ops (account/gauge/preference writes) change — they now retry at most twice instead of three times.max(the intended meaning of a per-op ceiling, matching TS/Swift/Kotlin).0/1: unchanged.A4c — retry-metadata parity guard
New
scripts/check-retry-metadata-parity.py(wired intomake check):behavior-model.jsonacross all six emitters (Python/Ruby/TS/Kotlin/Swift full tuple; Gomax-only from theoperationRetryMaxmap — Go consumes onlymax, so emitting the full inert tuple would be dead data). Fails on missing and stale/extra ops. Distinct from the regenerate-and-diff freshness gates.maxonly, Ruby = none. Behavioral proof lives in each SDK's retry tests.Tests / Verify
projects.list→ListProjectsmax:3;account.update_account_name→UpdateAccountNamemax:2), proven to fail against the snake_case-key bug (5 attempts vs 3).make py-check(480 tests, mypy, ruff, drift), Go drift + tests, and the parity guard (self-tested against injected stale/extra entries) all green.Part of the retry-contract follow-up (#456 / #460 / #461 / #476). Shares
go/templates/client.tmplwith #481; whichever merges second should be rebased and regenerated (make -C go generate) — disjoint regions, no textual conflict.