Skip to content

Fix VPP API retry recursion causing server OOM - #46659

Merged
nulmete merged 6 commits into
mainfrom
nulmete/vpp-api-derecursion
Jun 2, 2026
Merged

Fix VPP API retry recursion causing server OOM#46659
nulmete merged 6 commits into
mainfrom
nulmete/vpp-api-derecursion

Conversation

@nulmete

@nulmete nulmete commented Jun 2, 2026

Copy link
Copy Markdown
Member

Related issue: Resolves #46656

server/mdm/apple/vpp.do retried transient Apple errors by calling itself recursively, with the rate-limit branch nesting retry.Do inside retry.Do.

This change replaces the recursion with a single retry loop (respecting the prior 1 initial attempt + 3 retries), closes each response before retrying, honors Apple's Retry-After capped at 30s so that a multi-minute value can't block a synchronous request, and threads context through the VPP calls so the backoff is cancellable. The retry timings are otherwise unchanged from before.

Following @sgress454 suggestion, I considered routing this through the shared retry.Do helper (a single attempt wrapped in retry.Do + an error filter) but figured out that:

  • retry.Doowns its own wait schedule and its error filter returns an outcome enum rather than a duration, so it can't honor Apple's per-responseRetry-After` value.
  • also, I'd have to change the retry package to receive an extra ctx param so that the backoff is context-aware (which IMHO is more blast radius than this incident fix should carry).

Checklist for submitter

  • Changes file added for user-visible changes in changes/.
  • Timeouts are implemented and retries are limited to avoid infinite loops

Testing

  • Added/updated automated tests
  • QA'd all new/changed functionality manually

What was verified. The new automated test cannot run against main (the fix changes the VPP function signatures and adds the retry knobs), so to confirm the actual failure mode I checked out main and ran a small repro that drives the VPP client against an Apple endpoint that always returns the rate-limit error. On main, the call never returnsdo() recurses without bound — and the repro times out:

--- FAIL: TestReproUnboundedRecursionOnMain (10.00s)
    zz_repro_main_test.go:30: AssociateAssets did NOT return within 10s — unbounded retry recursion in do() on main
FAIL
FAIL	github.com/fleetdm/fleet/v4/server/mdm/apple/vpp	10.642s

vpp-retry-repro.md

On this branch the same scenario returns a bounded error promptly. That behavior is covered by the new TestDoRetryIsBoundedAndNonRecursive (bounded rate-limit retries, Retry-After honored-but-capped, and context cancellation), and the full server/mdm/apple/vpp package passes.
I did not perform an end-to-end QA against a live Apple endpoint.

Summary by CodeRabbit

  • Bug Fixes
    • Fixed a server out-of-memory crash that occurred when Apple VPP API repeatedly returned transient errors during VPP operations, including app installs, user registration, and license seat releases.

The Apple VPP App and Book Management client retried transient Apple
errors (HTTP 500 + Retry-After, and the rate-limit error number) by
calling itself recursively, with the rate-limit branch nesting retry.Do
inside retry.Do. When Apple's VPP endpoint flapped during a wave of
setup-experience installs, a single attempt fanned out into thousands of
in-flight retrying requests (each holding an open response body,
connection, cancel-watcher goroutine, OTel span and timer), exhausting
goroutines/heap and OOM-killing the server.

Replace the recursion with a flat, bounded retry loop (1 initial attempt
plus 3 retries), close each response before retrying, honor Apple's
Retry-After capped at 30s, and thread context through the VPP calls so
the backoff is cancellable.
@nulmete
nulmete marked this pull request as ready for review June 2, 2026 18:08
@nulmete
nulmete requested a review from a team as a code owner June 2, 2026 18:08
Copilot AI review requested due to automatic review settings June 2, 2026 18:08

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

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review to trigger a review and subscribe this PR to future pushes, or @claude review once for a one-time review.

Tip: disable this comment in your organization's Code Review settings.

@coderabbitai

coderabbitai Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 5cb546f1-0ba2-4c0a-a679-ad1ab4827d67

📥 Commits

Reviewing files that changed from the base of the PR and between 73532f1 and c6ec899.

📒 Files selected for processing (4)
  • changes/46656-vpp-api-derecursion-oom
  • ee/server/service/software_installers.go
  • server/mdm/apple/vpp/api.go
  • server/mdm/apple/vpp/api_test.go
✅ Files skipped from review due to trivial changes (1)
  • changes/46656-vpp-api-derecursion-oom
🚧 Files skipped from review as they are similar to previous changes (3)
  • ee/server/service/software_installers.go
  • server/mdm/apple/vpp/api_test.go
  • server/mdm/apple/vpp/api.go

Walkthrough

This PR fixes an out-of-memory crash in the VPP API client's retry logic. The problem was recursive retry handling: when Apple VPP returned error 9646 (rate-limited) or HTTP 500 with Retry-After, the code would recursively call the retry function, exhausting memory under sustained errors. The fix replaces recursion with a bounded, iterative retry loop capped at vppMaxAttempts, adds context.Context to VPP API functions for proper cancellation support, threads context through call sites, and introduces tests validating that retries are bounded and non-recursive.

Possibly related PRs

  • fleetdm/fleet#46382: Updates Apple VPP user and asset association endpoints (RegisterUser, AssociateAssets) with context-awareness and removes stale user recovery logic in the same functions modified here for retry derecursion.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The pull request title accurately and concisely summarizes the main change: fixing unbounded recursion in the VPP API retry logic that was causing server out-of-memory issues.
Description check ✅ Passed The description comprehensively addresses the linked issue, explains the root cause, documents the solution, covers the testing approach, and notes limitations of alternative approaches.
Linked Issues check ✅ Passed The code changes successfully address all requirements from issue #46656: recursion is eliminated, retries are non-recursive and bounded, and retry logic is robust to repeated rate-limit responses.
Out of Scope Changes check ✅ Passed All changes are directly related to fixing the VPP API retry recursion issue; the changes file documents the fix and no unrelated modifications are present.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch nulmete/vpp-api-derecursion

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@nulmete

nulmete commented Jun 2, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Full review triggered.

Copilot AI 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.

Pull request overview

This PR fixes runaway retry recursion in the Apple VPP client that could lead to unbounded retries and server OOM when Apple returns transient failures (notably error 9646 and HTTP 500 with Retry-After). It replaces recursive/self-nesting retries with a single bounded retry loop, ensures response bodies are closed before retrying, caps Retry-After, and threads context.Context through the VPP API so retries/backoff are cancellable.

Changes:

  • Convert VPP API entrypoints to accept context.Context and build requests with http.NewRequestWithContext.
  • Replace recursive retry behavior in do() with a bounded attempt loop and context-aware backoff sleep; cap Apple Retry-After waits.
  • Add/extend unit tests to ensure retry behavior is bounded, non-recursive, and context-cancellable.

Reviewed changes

Copilot reviewed 5 out of 6 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
server/service/activities.go Passes request context through to VPP disassociation calls.
server/mdm/apple/vpp/api.go Adds ctx-aware VPP request construction and replaces recursive retry logic with bounded loop + capped backoff.
server/mdm/apple/vpp/api_test.go Updates tests for ctx signatures and adds coverage ensuring retries are bounded/non-recursive and cancelable.
ee/server/service/vpp_users.go Passes ctx through to VPP v1 user registration calls.
ee/server/service/software_installers.go Passes ctx through to VPP assignment/associate/disassociate calls used during installs.
changes/46656-vpp-api-derecursion-oom Changelog entry documenting the OOM fix.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread server/mdm/apple/vpp/api.go Outdated
@@ -192,7 +192,7 @@ func (r *AssociateAssetsRequest) Validate() error {
// according the the request parameters provided.
Comment thread server/mdm/apple/vpp/api.go Outdated
Comment on lines +608 to +612
// For HTTP 5xx server error responses, a Retry-After header indicates how
// long the client must wait before making additional requests.
//
// https://developer.apple.com/documentation/devicemanagement/app_and_book_management/handling_error_responses#3742679
retryAfter := resp.Header.Get("Retry-After")
if resp.StatusCode == http.StatusInternalServerError && retryAfter != "" {
seconds, err := strconv.ParseInt(retryAfter, 10, 0)
if err != nil {
return fmt.Errorf("parsing retry-after header: %w", err)
if ra := resp.Header.Get("Retry-After"); resp.StatusCode == http.StatusInternalServerError && ra != "" {
Comment on lines +612 to 616
if ra := resp.Header.Get("Retry-After"); resp.StatusCode == http.StatusInternalServerError && ra != "" {
seconds, perr := strconv.ParseInt(ra, 10, 0)
if perr != nil {
return true, 0, fmt.Errorf("parsing retry-after header: %w", perr)
}

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

🧹 Nitpick comments (1)
changes/46656-vpp-api-derecursion-oom (1)

1-1: ⚡ Quick win

Consider broader wording to reflect full scope of the fix.

The changelog entry says "during app installs," but the fix applies to all VPP API operations (AssociateAssets, DisassociateAssets, RegisterUser, GetAssignments). The OOM crash could occur during user registration, assignment queries, or app/book disassociation—not just app installs.

Consider rephrasing to "during VPP (App and Book Management) operations" or "when interacting with Apple's VPP API" for more accurate coverage.

📝 Suggested wording
-* Fixed a server out-of-memory crash that could occur when Apple's VPP (App and Book Management) API repeatedly returned transient errors (HTTP 500 with Retry-After, or error 9646) during app installs.
+* Fixed a server out-of-memory crash that could occur when Apple's VPP (App and Book Management) API repeatedly returned transient errors (HTTP 500 with Retry-After, or error 9646) during VPP operations.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@changes/46656-vpp-api-derecursion-oom` at line 1, Update the changelog entry
to broaden scope: replace "during app installs" with wording that covers all VPP
API interactions (e.g., "during VPP (App and Book Management) operations" or
"when interacting with Apple's VPP API") and mention the affected operations
such as AssociateAssets, DisassociateAssets, RegisterUser, and GetAssignments so
readers understand the fix prevents OOM crashes across installs, registrations,
assignment queries, and disassociations.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@changes/46656-vpp-api-derecursion-oom`:
- Line 1: Update the changelog entry to broaden scope: replace "during app
installs" with wording that covers all VPP API interactions (e.g., "during VPP
(App and Book Management) operations" or "when interacting with Apple's VPP
API") and mention the affected operations such as AssociateAssets,
DisassociateAssets, RegisterUser, and GetAssignments so readers understand the
fix prevents OOM crashes across installs, registrations, assignment queries, and
disassociations.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c2d7cc43-b755-467a-8751-65d381ac9713

📥 Commits

Reviewing files that changed from the base of the PR and between 18b4ba0 and 3f07d7b.

📒 Files selected for processing (6)
  • changes/46656-vpp-api-derecursion-oom
  • ee/server/service/software_installers.go
  • ee/server/service/vpp_users.go
  • server/mdm/apple/vpp/api.go
  • server/mdm/apple/vpp/api_test.go
  • server/service/activities.go

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

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@changes/46656-vpp-api-derecursion-oom`:
- Line 1: Update the changelog line to indicate the fix applies to all VPP API
operations (not just app installs); mention that the out-of-memory crash could
occur when Apple's VPP API repeatedly returned transient errors (HTTP 500 with
Retry-After or error 9646) during operations such as app installs, user
registration and seat releases — reference the call sites in vpp_users.go and
activities.go so maintainers can confirm scope. Keep wording concise and
accurate, e.g. replace "during app installs" with "during VPP API operations
(e.g., app installs, user registration, seat releases)".

In `@ee/server/service/software_installers.go`:
- Around line 1712-1715: The rollback call to vpp.DisassociateAssets currently
uses the request ctx which may be canceled, causing the VPP seat-release to
abort and leak; modify the compensation path to call vpp.DisassociateAssets with
a detached context (e.g., context.Background()) or a short background context
with its own timeout instead of ctx so the release proceeds even if the request
context is canceled; keep the existing logging via svc.logger.ErrorContext and
preserve the "err", host.ID and vppApp.AdamID details when handling errors from
DisassociateAssets.

In `@server/mdm/apple/vpp/api_test.go`:
- Around line 741-757: The test currently only checks total duration <2s but
doesn't prove retries respect the capped backoff; modify the fake server handler
(setupFakeServer closure used by AssociateAssets) to record per-request
timestamps (e.g., append time.Now() to a slice) each time it increments calls,
then after AssociateAssets returns assert calls == vppMaxAttempts and that
successive timestamp differences are at least the capped backoff (maxVPPBackoff)
minus a small jitter and less than the large Retry-After value — this will fail
if do() ignores or skips the capped Retry-After delays.

In `@server/mdm/apple/vpp/api.go`:
- Around line 541-593: The loop in do() currently returns a generic fmt.Errorf
when the local VPP retry budget is exhausted, which prevents callers (like
GetConfig) from recognizing it as a non-retryable VPP error; change the final
return so it returns an ErrorResponse (the same concrete type GetConfig treats
as terminal) with a clear message and identifying code so upstream retry logic
will suppress further retries. Specifically, replace the final fmt.Errorf(...)
with constructing and returning an *ErrorResponse (or the existing VPP error
struct used elsewhere) containing the exhausted-attempts message and an
appropriate status/code (e.g., a distinct code or "retry budget exhausted" text)
so callers can detect and stop retrying.

In `@server/service/activities.go`:
- Around line 244-245: After the cancel transaction succeeds, the call to
vpp.DisassociateAssets should not use the request-scoped ctx because a
canceled/timeout request will abort the seat-release; replace the request
context with a detached context (e.g., context.Background() or a server-wide
non-cancelable context) when calling vpp.DisassociateAssets(tokenDB.Token, req)
so the VPP seat release runs to completion even if the HTTP request was
canceled; keep passing the same tokenDB.Token and req but invoke
vpp.DisassociateAssets with the detached context instead of the request ctx.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 0c9c6d34-5486-4241-b7e2-b20c3eff00cc

📥 Commits

Reviewing files that changed from the base of the PR and between 18b4ba0 and 3f07d7b.

📒 Files selected for processing (6)
  • changes/46656-vpp-api-derecursion-oom
  • ee/server/service/software_installers.go
  • ee/server/service/vpp_users.go
  • server/mdm/apple/vpp/api.go
  • server/mdm/apple/vpp/api_test.go
  • server/service/activities.go

Comment thread changes/46656-vpp-api-derecursion-oom Outdated
Comment thread ee/server/service/software_installers.go Outdated
Comment thread server/mdm/apple/vpp/api_test.go
Comment thread server/mdm/apple/vpp/api.go Outdated
Comment thread server/service/activities.go Outdated

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

♻️ Duplicate comments (1)
server/mdm/apple/vpp/api_test.go (1)

785-805: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

The new clamp test still doesn't prove the retry loop honors the capped delay.

Lines 800-804 confirm doVPPAttempt returns maxVPPBackoff, but they don't verify do() actually waits that long before the next attempt. Since Lines 741-757 only assert the whole call finishes in under 2s, the suite would still pass if retries happened immediately. Please add per-attempt timestamp assertions in the bounded retry test so skipped backoff fails deterministically.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/mdm/apple/vpp/api_test.go` around lines 785 - 805, The test currently
only checks doVPPAttempt returns a clamped duration but doesn't prove the
higher-level retry loop (do) actually waits that long; modify the bounded-retry
test that exercises do (the test around the retry loop) to record per-attempt
timestamps from the fake server handler (e.g., push time.Now() into a slice each
request) and after the call assert that the interval between attempts meets the
expected backoff (or at least is non-trivially >= the clamped maxVPPBackoff or a
small tolerance), so any skipped backoff will deterministically fail; reference
the do function and the existing TestDoVPPAttemptClampsRetryAfter/doVPPAttempt
behavior to locate where to add the timestamp capture and assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@server/mdm/apple/vpp/api_test.go`:
- Around line 785-805: The test currently only checks doVPPAttempt returns a
clamped duration but doesn't prove the higher-level retry loop (do) actually
waits that long; modify the bounded-retry test that exercises do (the test
around the retry loop) to record per-attempt timestamps from the fake server
handler (e.g., push time.Now() into a slice each request) and after the call
assert that the interval between attempts meets the expected backoff (or at
least is non-trivially >= the clamped maxVPPBackoff or a small tolerance), so
any skipped backoff will deterministically fail; reference the do function and
the existing TestDoVPPAttemptClampsRetryAfter/doVPPAttempt behavior to locate
where to add the timestamp capture and assertions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 8e99ce6d-e2a8-4a02-813d-40954072ced3

📥 Commits

Reviewing files that changed from the base of the PR and between 3f07d7b and 73532f1.

📒 Files selected for processing (2)
  • server/mdm/apple/vpp/api.go
  • server/mdm/apple/vpp/api_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • server/mdm/apple/vpp/api.go

cdcme
cdcme previously approved these changes Jun 2, 2026
@codecov

codecov Bot commented Jun 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 83.07692% with 11 lines in your changes missing coverage. Please review.
✅ Project coverage is 66.89%. Comparing base (3b54b0e) to head (c6ec899).
⚠️ Report is 39 commits behind head on main.

Files with missing lines Patch % Lines
server/mdm/apple/vpp/api.go 82.25% 6 Missing and 5 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #46659      +/-   ##
==========================================
+ Coverage   66.84%   66.89%   +0.04%     
==========================================
  Files        2809     2810       +1     
  Lines      223540   224190     +650     
  Branches    11354    11354              
==========================================
+ Hits       149427   149963     +536     
- Misses      60568    60603      +35     
- Partials    13545    13624      +79     
Flag Coverage Δ
backend 68.61% <83.07%> (+0.04%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@nulmete
nulmete requested a review from cdcme June 2, 2026 18:54
@nulmete
nulmete merged commit a335b3e into main Jun 2, 2026
41 checks passed
@nulmete
nulmete deleted the nulmete/vpp-api-derecursion branch June 2, 2026 19:18
georgekarrv added a commit that referenced this pull request Jun 2, 2026
…46684)

Cherry-pick of #46659 into the RC branch.

Co-authored-by: Nico <32375741+nulmete@users.noreply.github.com>
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.

Retry behavior when rate-limited by Apple causes runaway memory/CPU usage

4 participants