Skip to content

[CXP-913] fix: retry Okta rate limits instead of failing provisioning tasks - #197

Merged
mateovespConductor merged 8 commits into
mainfrom
mateovesp/fix-grant-revoke-calls-have-no-rate-limit-awareness
Sep 10, 2026
Merged

mateovespConductor merged 8 commits into
mainfrom
mateovesp/fix-grant-revoke-calls-have-no-rate-limit-awareness

Conversation

@mateovespConductor

@mateovespConductor mateovespConductor commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Okta 429s on provisioning endpoints reached baton-sdk as codes.Unknown. The
retryer only retries Unavailable / DeadlineExceeded, so tasks hard-failed
instead of waiting for the rate limit to reset — thousands got stuck needing
manual retry.

The v2 SDK exhausts its internal retries and returns a bare error, dropping the
response and its rate-limit headers. Our classifier had no 429 case.

Changes

  • Classify Okta rate limits as codes.Unavailable (helpers.go)
  • Route every Grant/Revoke error return through the classifier (fixes %s → %w)
  • Rate-limit annotations on Grant/Revoke success paths
  • 5xx errors preserve the Okta error text, including x-okta-request-id

@linear-code

linear-code Bot commented Aug 20, 2026

Copy link
Copy Markdown

CXP-913

Comment thread pkg/connector/helpers.go Outdated
Comment thread pkg/connector/app.go Outdated
Comment thread pkg/connector/connector.go Outdated
@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Connector PR Review: [CXP-913] fix: retry Okta rate limits instead of failing provisioning tasks

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

Review Summary

The new commit addresses the prior finding directly: all four GrantAlreadyExists early returns (app.go:586, app.go:662, role.go:469, resource_sets_bindings.go:327) now build from rateLimitAnnotations(response) and append the marker, matching revokeNotFoundOrError. I verified annotations.Append has a pointer receiver so the mutation sticks, that response is in scope and nil-safe at each site (rateLimitAnnotations guards both a nil resp and a nil embedded resp.Response), and that the 409 path still classifies to codes.AlreadyExists via GrpcCodeFromHTTPStatus. The full PR diff was re-scanned for security and correctness — no new issues; I also confirmed the earlier assignedUser.LastUpdated / createdRole.Created nil derefs are gone, appBuilder in the test file now passes all six arguments, the connector.go backoff bump is no longer in the diff, and the three oktaRateLimitTexts literals match the vendored SDK exactly (requestExecutor.go:549, :569, :574).

Open suggestion threads from earlier rounds are unchanged and not re-reported here (strings.Contains rate-limit matching, the nil embedded-response guard in handleOktaResponseError, the bare no-op return at resource_sets_bindings.go:402, the role-Grant group branch falling through to an error on already-assigned, listMembersOfBinding pagination, and the nil-user deref after GetUser).

Security Issues

None found.

Correctness Issues

None found.

Suggestions

None.

@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.

Comment thread pkg/connector/helpers.go Outdated

// isRateLimitError reports a 429 from the response status, or from the "too many
// requests" sentinel once the v2 SDK exhausts its own retries and drops the response.
func isRateLimitError(resp *okta.Response, err error) bool {

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.

This 429 → Unavailable classification is only routed through group.go and app.go. The role Grant/Revoke paths (role.go) and resource_sets_bindings.go still return the raw error with %s/%v, so a rate limit on those paths stays codes.Unknown and the SDK retryer skips it — the exact failure this PR fixes, just on the provisioning paths it doesn't touch. Since CXP-913 is about retrying rate limits across provisioning, can we route those sites through handleOktaResponseError too? As-is, role and resource-set-binding grants keep hard-failing on 429.

Comment thread pkg/connector/helpers.go Outdated
if resp != nil && resp.StatusCode == http.StatusTooManyRequests {
return true
}
return err != nil && strings.Contains(err.Error(), "too many requests")

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.

nit: the v2 SDK emits this sentinel as errors.New("too many requests") with a nil response, so an exact match is safer than strings.Contains here — otherwise any unrelated error whose text happens to include the phrase (e.g. a validation error echoing user input) gets classified as a rate limit and burns retries on a permanent failure. Something like resp == nil && err != nil && err.Error() == "too many requests" pins it to the real case (the 429-with-response path is already handled above).

Comment thread pkg/connector/connector.go Outdated
okta.WithCache(cc.Cache),
okta.WithCacheTti(cacheTTI),
okta.WithCacheTtl(cacheTTL),
okta.WithRateLimitMaxBackOff(oktaRateLimitMaxBackoffSeconds),

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.

question: this doubles the SDK default (30s → 60s) for every request, sync included, not just provisioning. With MaxRetries(2) and no RequestTimeout, a rate-limited call can block ~120s in-process before the connector sees the error and can checkpoint. Is the longer ceiling intended for the sync path too, or would it be worth pairing it with an explicit okta.WithRequestTimeout(...)?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

heads up — this line isn't in the diff anymore, git diff origin/main...HEAD -- pkg/connector/connector.go comes back completely empty on the current tip. looks like the backoff bump got pulled out in a later push (maybe in response to this exact thread?), but the PR description still says "Backoff ceiling 30s → 60s" which is now stale/inaccurate. worth updating the description so it matches what's actually shipping.

@luisina-santos
luisina-santos force-pushed the mateovesp/fix-grant-revoke-calls-have-no-rate-limit-awareness branch from 5ac3c2a to 4d9db1b Compare August 25, 2026 18:05
Comment thread pkg/connector/app.go Outdated
Comment thread pkg/connector/helpers.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.

Blocking issues found — see review comments. No new blocking issues in this pass, but the five correctness findings from the previous review are still unaddressed at 4d9db1b (restated in the summary comment).

Comment thread pkg/connector/app.go
}

l.Warn("App Membership has been created.",
l.Debug("App Membership has been created.",

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.

*assignedUser.LastUpdated is deref'd with no nil check (same at :509).

https://github.com/okta/okta-sdk-golang/blob/v2.20.0/okta/appUser.go#L39

@mateovespConductor
mateovespConductor force-pushed the mateovesp/fix-grant-revoke-calls-have-no-rate-limit-awareness branch from 4d9db1b to 6726b21 Compare September 8, 2026 16:05
Comment thread pkg/connector/helpers.go Outdated
Comment thread pkg/connector/app.go Outdated
Comment thread pkg/connector/resource_sets_bindings.go
Comment thread pkg/connector/role.go Outdated
Comment thread pkg/connector/helpers.go

@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.

Comment thread pkg/connector/resource_sets_bindings.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.

@FeliLucero1 FeliLucero1 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.

One more small thing, not inline-commentable since it's outside the diff hunk: group.go:511 (groupResourceType.Grant, the AddUserToGroup error path) returns nil, handleOktaResponseError(response, err) with no fmt.Errorf wrap, so it's missing the okta-connector: prefix everything else uses — unlike the Revoke path right below it in the same file, which now gets the prefix via revokeNotFoundOrError. Low priority, there are ~136 other spots in pkg/connector with the same gap, but flagging since you're already touching this exact function for the rate-limit annotations.

@FeliLucero1

Copy link
Copy Markdown

Quick note — I replied to 3 existing review threads earlier today, but 2 of them were already resolved and all 3 are marked outdated by GitHub, so they're collapsed by default and easy to miss. Linking them here so they don't get lost:

  • resource_sets_bindings.go:400 — the GrantAlreadyRevoked no-op path was deliberately dropped in the latest commit (afb7ac7), not just missing
  • role.go:511 — pushback on the "delete the dead branches" suggestion, since sync already emits real group→role grants that would need the guard fixed, not removed
  • connector.go:436 — the backoff bump isn't in the diff anymore, so the PR description's "30s → 60s" claim is now stale

Worth expanding "Show resolved"/"Show outdated" on the Files changed tab to see them inline.

Okta 429s reached baton-sdk as codes.Unknown, so the retryer rejected
them and provisioning tasks hard-failed instead of waiting. Classify
them as codes.Unavailable and make Revoke idempotent on 404.
…ith a

  synthetic rate-limit description, so the SDK retryer waits out Okta's reset
  window instead of hard-failing the task.
- Route every Grant/Revoke error path through handleOktaResponseError; only
  group and app were classified before, so a 429 on role, resource-set or
  resource-set-binding provisioning still surfaced as codes.Unknown.
- Return GrantAlreadyRevoked/GrantAlreadyExists when the target is already in
  the desired state instead of failing the operation.
- Report rate-limit annotations from successful provisioning responses,
  matching what the read paths already emit.
- Drop the unguarded LastUpdated/CreatedAt derefs from success logs and lower
  those logs to Debug.
- Add error-classification tests that drive the real vendored Okta SDK.
- Classify the two 429s that leave the SDK's retry loop through
  Get429BackoffTime, not just the exhausted-retries sentinel. Okta's
  concurrency limit omits X-Rate-Limit-Reset and hit that path, so those
  429s still reached baton-sdk as codes.Unknown.
- Stop shadowing the SDK's error when re-parsing the response body, so 5xx
  failures keep the x-okta-request-id the SDK appends for support triage.
- Return GrantAlreadyRevoked when the principal is not a member of a
  resource-set binding, matching what the role paths already do.
- Add tests for both classification paths and for the rate-limit
  annotations on Grant/Revoke success, which nothing asserted before.
- Inline the revoke-success log again; a helper wrapping one debug call
  was not worth the indirection.
@mateovespConductor
mateovespConductor force-pushed the mateovesp/fix-grant-revoke-calls-have-no-rate-limit-awareness branch from afb7ac7 to 5d12421 Compare September 10, 2026 14:56
}

func newTestAppBuilder(client *okta.Client) *appResourceType {
return appBuilder("", "", false, nil, client)

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.

🟠 Bug: appBuilder takes six parameters at this SHA — appBuilder(domain, apiToken string, syncInactiveApps, skipAppGroups bool, filterEmailDomains []string, client *okta.Client) (pkg/connector/app.go:73). This call passes five, with nil landing on the skipAppGroups bool parameter, so the whole connector test package fails to compile and none of the new tests (or the pre-existing ones) run.

Suggested change
return appBuilder("", "", false, nil, client)
return appBuilder("", "", false, false, nil, client)

return rateLimitAnnotations(response), nil
}

return nil, nil

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.

🟡 Suggestion: when the principal isn't among the binding's members, this returns bare nil, nil. Every other revoke path in this PR now signals idempotency, and this is the most common "already revoked" shape for bindings (the list succeeds, the member just isn't there). Returning annotations.New(&v2.GrantAlreadyRevoked{}), nil here would make it consistent with revokeNotFoundOrError.

Comment thread pkg/connector/app.go
Comment on lines +594 to 599
email := unknownProfileValue
if user.Profile != nil {
if v, ok := (*user.Profile)[profileFieldEmail].(string); ok {
email = v
}
}

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.

🟡 Suggestion: the new user.Profile != nil guard is an improvement, but user itself can still be nil here. UserResource.GetUser returns (nil, resp, nil) when a 2xx carries an empty body — the SDK only unmarshals when len(bodyBytes) > 0 — so user.Profile would panic. Same shape applies to assignedUser.Id (line 619), assignedGroup.Id (line 671), and createdRole.Id in role.go. Folding it into the existing check (if user != nil && user.Profile != nil) covers it.

Comment thread pkg/connector/role.go
Comment on lines 502 to +512
@@ -507,22 +509,21 @@ func (g *roleResourceType) Grant(ctx context.Context, principal *v2.Resource, en
)
}

return nil, fmt.Errorf("okta-connector: %v", errOkta)
return nil, fmt.Errorf("okta-connector: failed to assign role to group: %w", handleOktaResponseError(response, errors.Join(&errOkta, err)))

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.

🟡 Suggestion: the user branch returns annotations.New(&v2.GrantAlreadyExists{}), nil on alreadyAssignedRole (line 469), but this group branch only logs and then falls through to the error return on line 512 — so an already-assigned group role reports failure. It's currently unreachable dead code (the guard at line 431 rejects every non-user principal before the switch), which is worth resolving one way or the other: either add the matching GrantAlreadyExists return, or drop the resourceTypeGroup.Id cases from Grant/Revoke since they can't be entered.

Comment thread pkg/connector/helpers.go
Comment on lines +237 to +243
if status.Code(classified) == codes.NotFound {
l.Debug(notFoundMsg,
zap.String("principal_id", principal.Id.String()),
zap.String("principal_type", principal.Id.ResourceType),
)
return annotations.New(&v2.GrantAlreadyRevoked{}), nil
}

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.

🟡 Suggestion: the already-revoked path discards the rate-limit state even though resp is in hand and carries the X-Rate-Limit-* headers. Every other provisioning path in this PR now reports remaining quota via rateLimitAnnotations, so merging it here would keep reporting consistent: build from rateLimitAnnotations(resp) and append &v2.GrantAlreadyRevoked{}. (confidence: medium)

Comment thread pkg/connector/helpers.go
Comment on lines +207 to +211
return uhttp.WrapErrorsWithRateLimitInfo(codes.Unavailable, &http.Response{
Status: "429 Too Many Requests",
StatusCode: http.StatusTooManyRequests,
Header: http.Header{},
}, err)

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.

🟡 Suggestion: with an empty Header, ratelimit.ExtractRateLimitData falls into its no-headers-on-429 default and sets ResetAt = now + 60s (ratelimit/http.go:134), so the retryer always waits a fixed minute here — it isn't recovering Okta's actual reset window, which the SDK already discarded. That's a defensible choice, but the comment on TestRateLimitExhaustedCarriesRetryDetail ("waits out Okta's reset window instead of its short fixed backoff") describes the opposite; worth correcting so the 60s default is an explicit, documented decision. (confidence: high)

@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 the retry-detail test comment: the 60s reset is our default, not Okta's"
Comment thread pkg/connector/app.go Outdated
@@ -590,14 +586,16 @@ func (g *appResourceType) Grant(ctx context.Context, principal *v2.Resource, ent
return annotations.New(&v2.GrantAlreadyExists{}), nil

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.

🟡 Suggestion (high confidence): this already-exists path returns bare annotations even though response (line 553) is in hand and carries the X-Rate-Limit-* headers. This is the same gap that was just closed in revokeNotFoundOrError (helpers.go:242), and "rate-limit annotations on Grant/Revoke success paths" is one of the four changes this PR lists — an already-assigned grant is a success path too. Same at app.go:660, role.go:469, and resource_sets_bindings.go:327. Consider annos := rateLimitAnnotations(response); annos.Append(&v2.GrantAlreadyExists{}); return annos, nil.

@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.

@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.

@mateovespConductor
mateovespConductor dismissed github-actions[bot]’s stale review September 10, 2026 15:58

I already fixed what this bot asked me to, but it's glitchy

@mateovespConductor
mateovespConductor merged commit 87d2337 into main Sep 10, 2026
11 checks passed
@mateovespConductor
mateovespConductor deleted the mateovesp/fix-grant-revoke-calls-have-no-rate-limit-awareness branch September 10, 2026 16:14
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.

6 participants