Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -808,7 +808,7 @@ tools:
# Spec-shape lints
#------------------------------------------------------------------------------

.PHONY: check-bucket-flat-parity validate-api-gaps check-deprecation-parity kt-check-optional-arrays-and-scalars check-fixture-coverage check-idempotency-parity
.PHONY: check-bucket-flat-parity validate-api-gaps check-deprecation-parity kt-check-optional-arrays-and-scalars check-fixture-coverage check-idempotency-parity check-retry-metadata-parity

# Verify every bucket-scoped GET list operation has a flat-path counterpart
# (or is justified in spec/bucket-scoped-allowlist.txt). Cross-project SDK
Expand Down Expand Up @@ -854,6 +854,9 @@ kt-check-optional-arrays-and-scalars:
check-idempotency-parity:
@./scripts/check-idempotency-parity

check-retry-metadata-parity:
@python3 ./scripts/check-retry-metadata-parity.py

#------------------------------------------------------------------------------
# Combined targets
#------------------------------------------------------------------------------
Expand All @@ -876,7 +879,7 @@ generate:
@echo "==> Generation complete"

# Run all checks (Smithy + Go + TypeScript + Ruby + Kotlin + Swift + Python + Behavior Model + Conformance + Provenance + Actions lint)
check: lint-actions sync-spec-version-check smithy-check behavior-model-check provenance-check sync-api-version-check url-routes-check go-check-drift go-check-wrapper-drift go-check-generated-drift auth-routable-check kt-check-drift swift-check-drift go-check ts-check rb-check kt-check swift-check py-check conformance check-bucket-flat-parity validate-api-gaps check-deprecation-parity check-fixture-coverage kt-check-optional-arrays-and-scalars check-idempotency-parity
check: lint-actions sync-spec-version-check smithy-check behavior-model-check provenance-check sync-api-version-check url-routes-check go-check-drift go-check-wrapper-drift go-check-generated-drift auth-routable-check kt-check-drift swift-check-drift go-check ts-check rb-check kt-check swift-check py-check conformance check-bucket-flat-parity validate-api-gaps check-deprecation-parity check-fixture-coverage kt-check-optional-arrays-and-scalars check-idempotency-parity check-retry-metadata-parity
@echo "==> All checks passed"

# Clean all build artifacts
Expand Down
6 changes: 6 additions & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,12 @@ END

**Naming note:** `max_retries` means total attempts (including the initial request), not the number of retries after the first attempt. With `max_retries = 3`, the transport makes at most 3 attempts total (1 initial + 2 retries). This name is inherited from the shipping Ruby SDK; the behavior-model.json uses `retry.max` with identical semantics.

**Per-operation retry ceiling.** Each operation carries a per-op `retry.max` in behavior-model.json (183 ops at `3`, 43 at `2`). **TypeScript and Swift** drive their retry loops directly from this per-op value, which is unambiguous there because neither exposes a numeric client-wide cap — only an on/off (`enableRetry`). **Kotlin does expose one** (`BasecampConfig.maxRetries`), but resolves the two as `opRetry?.maxRetries ?: config.maxRetries`, so the per-op value *overrides* the client's rather than bounding it: a Kotlin caller who lowers `maxRetries` to `1` still gets the operation's count. That is a divergence from this section, not a sanctioned variation — tracked in #485. Generated Go and Python expose a numeric client cap (`RetryConfig.MaxRetries` / `config.max_retries`) *and* honor the per-op value as a **ceiling**: `effective_attempts = min(client_cap, op_max)`. The ceiling can only reduce attempts below the client cap, never raise them, so a client that lowered its cap (e.g. to `0`/`1` to disable/restrict retries) is still honored. Because every op's `max` is ≤ the default cap of `3`, a default or raised Go/Python client makes exactly the per-op number of attempts — matching TS/Swift/Kotlin. Observable changes from the former client-wide behavior, by client configuration:

- **Default client (`max_retries = 3`):** only the **9 idempotent `max:2` operations** (account/gauge/preference writes: `UpdateAccountName`, `UpdateAccountLogo`, `RemoveAccountLogo`, `UpdateMyPreferences`, `DisableOutOfOffice`, `MarkAsRead`, `ToggleGauge`, `UpdateGaugeNeedle`, `DestroyGaugeNeedle`) change — they now retry at most twice instead of three times. The other 178 retry-eligible ops are unaffected (`min(3, 3) = 3`).
- **Client that raised its cap above 3:** **all 187 retry-eligible operations** are now clamped to their per-op `max` (178 to `3`, 9 to `2`) instead of retrying up to the raised cap. This is the intended meaning of a per-op ceiling and brings Go/Python into line with TS/Swift/Kotlin, which never retry beyond the per-op `max`. (Go and Python are, however, the only two that also honor a caller who wants *fewer* attempts than the operation declares — see the Kotlin note above.)
- **Client that lowered its cap to `0`/`1`:** unchanged — the cap still wins (`min(cap, op_max) = cap`). Go/Python consume only `max`; the emitted `base_delay_ms`/`backoff`/`retry_on` remain inert per-op metadata for them (retained for parity — see `scripts/check-retry-metadata-parity.py`). Ruby retries GET only and bounds its loop by `config.max_retries` alone, so the per-op `max` is not enforced there at all — the opposite gap from Kotlin's. All per-op retry fields are inert in Ruby.

**Recommended default:** A connect timeout of 10 seconds is recommended but not a required config field. Only Ruby exposes this (Faraday `open_timeout = 10`); other SDKs use their HTTP library's default.

### Environment Variable Mapping (optional convention)
Expand Down
103 changes: 103 additions & 0 deletions go/pkg/basecamp/generated_perop_retry_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package basecamp

import (
"context"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"

"github.com/basecamp/basecamp-sdk/go/pkg/generated"
)

// These tests pin the generated client's per-operation retry ceiling. The
// x-basecamp-retry.maxAttempts value for each operation is emitted into the
// operationRetryMax map (read via GetOperationRetryMax), and doWithRetry applies
// it as a ceiling (upper bound on attempts): effective attempts =
// min(client cap, op retry max). This
// matches how TS/Swift/
// Kotlin drive their retry loops directly from the per-op max, while still
// honoring a Go client that lowered its cap. DisableOutOfOffice carries
// RetryMax:2 (an idempotent account write); CompleteTodo carries RetryMax:3.
// They stay outside pkg/generated per the repo rule.

func TestGeneratedPerOpRetryMax_CeilingBindsBelowClientCap(t *testing.T) {
cases := []struct {
name string
operation string
clientCap int
wantAttempts int32
}{
// DisableOutOfOffice has RetryMax:2. With the default cap (3) the op
// ceiling binds: min(3, 2) == 2, not the client-wide 3.
{"op ceiling below default cap", "DisableOutOfOffice", 3, 2},
// Raising the client cap cannot exceed the op ceiling: min(5, 2) == 2.
{"op ceiling below raised cap", "DisableOutOfOffice", 5, 2},
// Lowering the client cap below the op ceiling is honored: min(1, 2) == 1.
{"client cap below op ceiling", "DisableOutOfOffice", 1, 1},
// CompleteTodo has RetryMax:3, equal to the default cap: min(3, 3) == 3
// (control — unchanged behavior).
{"op ceiling equals cap", "CompleteTodo", 3, 3},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var attempts int32
server := httptest.NewServer(always503(&attempts))
defer server.Close()

client, err := generated.NewClient(server.URL, generated.WithRetryConfig(fastRetryConfig(tc.clientCap)))
if err != nil {
t.Fatalf("NewClient: %v", err)
}

resp := mustCallIdempotent(t, client, tc.operation)
if resp != nil {
_ = resp.Body.Close()
}

if got := atomic.LoadInt32(&attempts); got != tc.wantAttempts {
t.Errorf("%s with client cap %d made %d attempts, want %d (effective = min(cap, RetryMax))",
tc.operation, tc.clientCap, got, tc.wantAttempts)
}
})
}
}

// TestGeneratedPerOpRetryMax_MetadataMatchesModel is a lightweight guard that
// the emitted per-op retry max is present and positive for the ops under test,
// so the ceiling test above is exercising real per-op data rather than a zero
// default.
func TestGeneratedPerOpRetryMax_MetadataPresent(t *testing.T) {
for op, want := range map[string]int{"DisableOutOfOffice": 2, "CompleteTodo": 3} {
got, ok := generated.GetOperationRetryMax(op)
if !ok {
t.Fatalf("no retry max for %s", op)
}
if got != want {
t.Errorf("%s retry max = %d, want %d", op, got, want)
}
}
}

// mustCallIdempotent invokes an idempotent no-body operation by name so the
// ceiling test can parameterize over operations.
func mustCallIdempotent(t *testing.T, client *generated.Client, operation string) *http.Response {
t.Helper()
switch operation {
case "DisableOutOfOffice":
r, err := client.DisableOutOfOffice(context.Background(), "99999", 100)
if err != nil {
t.Fatalf("DisableOutOfOffice: %v", err)
}
return r
case "CompleteTodo":
r, err := client.CompleteTodo(context.Background(), "99999", 100)
if err != nil {
t.Fatalf("CompleteTodo: %v", err)
}
return r
default:
t.Fatalf("unknown operation %q", operation)
return nil
}
}
Loading
Loading