Skip to content

[CXH-2245] fix: classify DocuSign hourly-limit error as retryable, reduce per-user call amplification - #68

Open
FeliLucero1 wants to merge 30 commits into
mainfrom
fix-hourly-rate-limit-non-recoverable
Open

[CXH-2245] fix: classify DocuSign hourly-limit error as retryable, reduce per-user call amplification#68
FeliLucero1 wants to merge 30 commits into
mainfrom
fix-hourly-rate-limit-non-recoverable

Conversation

@FeliLucero1

@FeliLucero1 FeliLucero1 commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Description

  • Bug fix
  • New feature

Pylon #11445 (The Trade Desk, Odenis Peralta): initial full sync fails non-recoverably once a DocuSign account exceeds its hourly API-call budget (3000/hour). DocuSign returns HOURLY_APIINVOCATION_LIMIT_EXCEEDED and the connector surfaces it as a fatal InvalidArgument error rather than backing off.

  1. Root cause and fix (code-validated, the core of this PR). uhttp.GrpcCodeFromHTTPStatus maps this error's HTTP 400 to codes.InvalidArgument — a code the SDK's sync-retry loop (pkg/sync's Retryer, wired to SyncResourcesOp/SyncGrantsOp) treats as fatal; it only waits and retries on Unavailable/DeadlineExceeded. doRequestCommon now recognizes the errorCode independent of HTTP status (DocuSign is mid-migration from 400 to 429 for this condition and documents parsing errorCode, not status) and reclassifies it as codes.Unavailable with a RateLimitDescription attached via status details, so the SDK's existing retry loop picks it up and the sync pauses/resumes instead of failing outright.

  2. Reduced call amplification where feasible. userBuilder.Grants() called GetUserDetails once per user, every sync — real N+1 volume contributing to hitting the budget in the first place. The list response already carries the user's permission-profile name and status (captured on the resource's profile during List()), so Grants() now resolves the profile ID via GetPermissionProfiles (one account-wide call, already served from uhttp's default GET cache on repeat calls within a sync) for Active users, instead of a per-user call. Falls back to the original GetUserDetails call, unchanged, for every case that isn't confidently safe (non-active user, missing profile field, unresolvable/renamed profile name, or a failed GetPermissionProfiles call) — an earlier version of this cached GetPermissionProfiles for the builder's lifetime and skipped the active-user check entirely; dropped after a review found it could diverge from the old code for non-active users and duplicated caching uhttp's client already provides.

Two other findings from the same ticket (the GetUserByEmail-per-signing-group-member lookup in singing_groups.go, and the permission_profiles.go/singing_groups.go Grant/Revoke call sites, which the ticket miscited as sync-time amplification but are actually low-frequency provisioning actions) were investigated but left alone — not blocking this ticket's core "non-recoverable sync" complaint, and lower-confidence to change safely without a live tenant.

Operational note (from review): the SDK's retry loop clamps its wait to 60s regardless of the hour-long ResetAt this connector reports (pkg/retry's MaxDelay normalization). So an account that trips the hourly limit early in the hour retries roughly once a minute for the rest of that hour — ~60 calls against an already-exhausted budget, holding a sync slot open the whole time. Real improvement over a fatal failure, but not a single hour-long pause; the 60s cap lives in baton-sdk, not this connector.

Known limitation: the burst-limit variant (BURST_APIINVOCATION_LIMIT_EXCEEDED, DocuSign's other 30s rate limiter) is now also reclassified as retryable alongside the hourly one, but its exact errorCode/message string is a best-effort reconstruction — not confirmed against a live account or DocuSign's docs (flagged in code comments on the relevant constants). Worth verifying before this ships if anyone gets access to a real burst-limit response.

Useful links:

Pylon #11445 (The Trade Desk): initial full sync fails non-recoverably once
an account exceeds DocuSign's hourly API-call budget. DocuSign signals this
via a JSON error-body errorCode (HOURLY_APIINVOCATION_LIMIT_EXCEEDED) on
HTTP 400 — DocuSign is mid-migration to 429 and documents parsing errorCode
instead of relying on status code. uhttp.GrpcCodeFromHTTPStatus maps 400 to
codes.InvalidArgument, which the SDK's sync-retry loop (pkg/sync's Retryer,
wired to SyncResourcesOp/SyncGrantsOp) treats as fatal — it only waits and
retries on Unavailable/DeadlineExceeded — so an otherwise-recoverable rate
limit surfaced as a permanent sync failure with no checkpoint resume.

doRequestCommon now recognizes this errorCode independent of HTTP status
(so it keeps working once DocuSign flips to 429) and reclassifies it as
codes.Unavailable with a RateLimitDescription attached via status details,
so the SDK's existing retry loop picks it up and the sync pauses/resumes
instead of failing outright.
…#11445)

userBuilder.Grants() called GetUserDetails once per user, every sync — real
N+1 amplification contributing to accounts hitting DocuSign's hourly call
budget. The list response (GetUsers) already carries the user's permission
profile name and status, both already captured on the resource's profile
during List(). Grants now resolves the profile ID via GetPermissionProfiles
(one account-wide call, already served from uhttp's default GET cache on
repeat calls within a sync) instead, gated on status == Active — the same
distinction GetUserDetails.PermissionProfileID-empty already relies on, not
a new assumption. Falls back to the original per-user GetUserDetails call
unchanged whenever the user isn't Active, the profile field is absent, the
cached name no longer resolves (renamed/deleted since listing), or the
GetPermissionProfiles call fails.

An earlier version of this fix cached GetPermissionProfiles for the
builder's lifetime via sync.Once — dropped after review found it silently
diverged from the old code for non-active users (no status check at all)
and duplicated caching uhttp's GET client already provides by default.
@FeliLucero1 FeliLucero1 changed the title [Pylon #11445] fix: classify DocuSign hourly-limit error as retryable, reduce per-user call amplification [CXH-2245] fix: classify DocuSign hourly-limit error as retryable, reduce per-user call amplification Aug 11, 2026
@linear-code

linear-code Bot commented Aug 11, 2026

Copy link
Copy Markdown

CXH-2245

Comment thread pkg/connector/users.go Outdated
Comment thread pkg/connector/users.go Outdated
Comment thread pkg/client/helper.go Outdated
Comment thread pkg/connector/users_test.go
@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Connector PR Review: [CXH-2245] fix: classify DocuSign hourly-limit error as retryable, reduce per-user call amplification

Blocking Issues: 0 | Suggestions: 0 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base 2045ee89b8de.
Review mode: incremental since f56b9fa
View review run

Review Summary

The new commit (413cd99) is a single test rename — TestPermissionProfilesBuilder_Revoke_UnambiguousDefaultNameNoWarning to TestPermissionProfilesBuilder_Revoke_NoAmbiguityLog — which resolves the one outstanding finding from the previous review: the old name said "Warning" while the assertion it makes is about the absence of the ambiguous-match Debug log, and the new name matches what the test actually asserts via FilterMessageSnippet("ambiguous"). As required, the full PR diff was also re-scanned for security and correctness: reclassifyRateLimitError (errors.Join keeps codes.Unavailable and the RateLimitDescription reachable through errors.As / status.FromError, and uhttp.BaseHttpClient.Do already defers the body close, so the new non-nil-response early return leaks nothing), the sync-keyed getPermissionProfiles memoization (SyncID reset, non-cacheable rate-limit and context errors, the 5s TTL guard, mutex-protected shared state), the GetPermissionProfiles / GetPermissionProfilesFresh cache split, and the name-based fast-path grant resolution (permissionProfileName off the list response, ambiguous match falling back to GetUserDetails) all check out. No go.mod / go.sum changes, and the incremental artifact reported no dropped or truncated paths.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

None.

Note: this run could not write the machine-readable review-state marker (the CI shell blocked the inline JSON), so the next review will fall back to full-diff mode.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

…ifetime

userBuilder is registered once via ResourceSyncers and reused for the
connector process's lifetime, not reconstructed per sync — in service/
hosted mode that's many syncs sharing one builder instance. The cache
only checked "have I fetched this at all", so the first sync's snapshot
(or its cached failure) would silently serve every later sync forever.
Threads SyncOpAttrs.SyncID through Grants() -> tryFastPathGrant ->
getPermissionProfiles and resets the cache (and the transient-failure
counter) whenever the SyncID changes.
Surfaced by testing the SyncID-keyed memoization fix: even after that
fix correctly decided a new sync needed a fresh call, uhttp's GET cache
was still serving the previous sync's cached HTTP response underneath
it. userBuilder already calls this at most once per sync, so a cached
response here can only ever be a stale snapshot from a prior sync on
the same long-lived connector process — never a real saved call.
Comment thread pkg/client/client.go Outdated
Comment thread pkg/connector/users.go Outdated

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

…ead Pebble

Same root cause already fixed on PR #63/#64: GitHub Actions tests this
PR's merge-preview against main, and main is already on baton-sdk v0.25.0
(Pebble-by-default) even though this branch's own go.mod is still on
v0.24.2 — the merge cleanly picks up main's newer line since this PR
never touches it. The downloaded baton CLI (v0.4.5, built against SDK
v0.8.24) can't read a Pebble-format file.
…ion-forwarding comment

GetPermissionProfiles unconditionally bypassing the shared HTTP GET
cache saved userBuilder from cross-sync staleness, but List and Revoke
share this same call and don't have that risk — sharing uhttp's cache
between them when both fire in one sync used to cost 1 real request,
unconditional WithNoCache() silently made it 2. Added
GetPermissionProfilesFresh as the dedicated no-cache variant for
userBuilder's memoization; GetPermissionProfiles (List/Revoke) stays
cacheable.

Also corrected tryFastPathGrant's doc comment: it still attributed
"exactly one real call per sync" to uhttp's GET cache, which no longer
applies to the fresh variant — getPermissionProfiles' own memoization is
what guarantees that now.
Comment thread pkg/client/client_test.go Outdated
Comment thread pkg/client/client_test.go Outdated

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

…ntly

test-permission-profiles failed: the final grant succeeded, but the sync
run immediately after still read the pre-grant state, so the
verification query found no grants. sync-test@v3 already has a sleep
input built for exactly this (DocuSign write-propagation delay) — no
job here was using it. Added to all three jobs; only
test-permission-profiles has actually hit the race so far, but all
three share the same account and write pattern.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

@sergiocorral-conductorone sergiocorral-conductorone left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review

Comment thread pkg/client/client.go
Comment thread pkg/connector/users.go
Comment thread .github/workflows/ci.yaml Outdated
Comment thread pkg/client/client_test.go Outdated
Comment thread pkg/connector/users.go Outdated
Comment thread pkg/connector/users.go Outdated
Comment thread pkg/client/helper.go Outdated
Comment thread pkg/client/models.go Outdated
Comment thread pkg/client/helper.go Outdated
Comment thread pkg/connector/users.go Outdated
FeliLucero1 and others added 4 commits August 26, 2026 11:17
Resolves 4 conflicted files, all from independent, non-overlapping
additions on each side landing at the same location in the file:

- .github/workflows/ci.yaml: both sides independently added a
  BATON_STORAGE_ENGINE: sqlite pin with slightly different wording.
  Kept main's wording (evergreen) plus main's new `concurrency` block
  and BATON_SYNC_RESOURCE_TYPES allowlist var, dropped this branch's
  now-stale comment (it referenced a go.mod version mismatch that no
  longer exists post-merge). This branch's own `sleep: "3"` additions
  on the three sync-test steps were untouched by the conflict and are
  preserved.

- pkg/client/helper.go: this branch added the hourly-rate-limit
  reclassification helpers (reclassifyHourlyRateLimitError,
  isHourlyAPIInvocationLimitError) and a `time` import; main added
  IDFromHref. Kept both.

- pkg/connector/helper.go: this branch added permissionProfileIDByName
  and profileFieldPermission/Status/PermissionID; main added
  profileFieldHref, clmHrefWithID, clmPreferredHref, and
  clmSampleHrefsFrom, and replaced clmIDFromHref's literal
  implementation with one that delegates to the new client.IDFromHref.
  Kept both sides' additions. main's removal of
  isOptInFeatureUnavailableError (superseded by the Validate()/
  EnsureClmReady upfront CLM-readiness gate added in #63/#64) was
  applied automatically by git's 3-way merge, since this branch never
  touched that function — verified it has no remaining call sites.

- pkg/connector/helper_test.go: test-side companion to the above;
  kept TestPermissionProfileIDByName (this branch) alongside
  TestClmPreferredHref/TestClmSampleHrefsFrom (main), and merged
  imports. Dropped the now-orphaned codes/status imports along with
  the isOptInFeatureUnavailableError test they supported.

Verified with: go build ./..., go vet ./..., go test ./... -count=1
(all pass), golangci-lint run ./... (1 pre-existing goconst finding
in pkg/client/clmtest/handlers.go, present on origin/main already,
unrelated to this merge).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…iew gaps

Removes the unverified permission-profile-ID fast path from Grant resolution
(no live tenant to confirm it matches GetUserDetails' effective profile —
kept only the well-tested name-based path). Restores Revoke's pre-PR
first-match behavior for ambiguous default-profile names, now logged instead
of silent. Adds a 5s TTL-bounded short-circuit so concurrent Grants() workers
stop re-hitting an already-exhausted rate limit. Makes the hourly rate-limit
predicate case-insensitive and extends it to DocuSign's burst-limit variant.
Fixes a dropped-error-chain bug in reclassifyRateLimitError via errors.Join.

Also fixes issues a deep-code-review pass found in that work: a Warn-level
log that violated this repo's no-Warn convention (now Debug, matching
tryFastPathGrant's own fallback-log pattern); dead annotation-forwarding
plumbing on the rate-limit-error path (Grants() already discards annotations
whenever it returns an error, so the SDK never saw them either way); a
write-only, never-read struct field; and duplicated hourly/burst matching
logic factored into one shared helper.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…sion-profile cache

Two sergiocorral findings on PR68 that didn't make it into the prior fix
batch. tryFastPathGrant's propagated rate-limit error was returned bare,
unlike every other error return in this file — now wrapped with %w (verified
this still preserves codes.Unavailable/RateLimitDescription through
errors.As, same property reclassifyRateLimitError's errors.Join already
relies on). getPermissionProfiles now checks ctx.Err() before taking
permissionProfilesMu, since the lock is held across a real HTTP round-trip
and an already-cancelled caller shouldn't queue behind it for nothing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Last of the sergiocorral findings on PR68. attrs.SyncID is only populated
when baton-sdk's own version check passes; when it isn't, every Grants()
call arrives with syncID == "", which equals permissionProfilesSyncID's zero
value — so the cache's mismatch check would never fire again after the first
write, silently reverting to the exact process-lifetime memoization bug the
SyncID keying exists to prevent. Now an empty syncID disables the cache
entirely (every call is real, matching pre-fast-path cost) and logs once so
the condition is observable instead of a silent regression.

Updated three existing tests that were relying on the empty-SyncID zero
value as a stand-in for "one consistent sync" to use a real SyncID instead,
so they still exercise the caching behavior they're named for.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Empty commit to re-run stuck/cancelled GitHub Actions workflows on PR #68.

Co-authored-by: Cursor <cursoragent@cursor.com>
Comment thread pkg/connector/permission_profiles_test.go Outdated
Comment thread pkg/connector/permission_profiles.go
Comment thread pkg/connector/helper.go Outdated
Comment thread pkg/connector/permission_profiles_test.go Outdated
Comment thread pkg/connector/helper.go Outdated

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

Fix SA5011 in users_test, race-safe updateCalls recording, align
ambiguous-profile docs with Debug logging, use first-match in
permissionProfileIDByName, and fix stale reclassifyRateLimitError doc.

Co-authored-by: Cursor <cursoragent@cursor.com>
Comment thread pkg/connector/permission_profiles_test.go Outdated

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

Rename UnambiguousDefaultNameNoWarning to NoAmbiguityLog so the test
name reflects FilterMessageSnippet("ambiguous") rather than Warn level.

Co-authored-by: Cursor <cursoragent@cursor.com>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

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.

7 participants