Skip to content

fix(intelligent-assistant): retry vector store attach/upload on HTTP 429 - #4786

Open
JslYoon wants to merge 8 commits into
redhat-developer:mainfrom
JslYoon:fix-vector-store-429-retry
Open

JslYoon wants to merge 8 commits into
redhat-developer:mainfrom
JslYoon:fix-vector-store-429-retry

Conversation

@JslYoon

@JslYoon JslYoon commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Description

lightspeed-core bounds concurrent file uploads and vector store attaches with per-endpoint semaphores (max_concurrent_file_uploads / max_concurrent_vector_store_attaches) and rejects excess requests with 429 Too Many Requests rather than queuing them. Bursty notebook uploads therefore failed intermittently — observed as repeated:

POST /v1/vector-stores/{id}/files HTTP/1.1" 429 Too Many Requests

Changes

  • Add a fetchWithRetry helper in VectorStoresOperator that retries on 429, honoring the Retry-After header when present and otherwise backing off exponentially (capped, up to 8 attempts).
  • Use it for the two rate-limited endpoints: file upload (POST /v1/files) and vector store attach (POST /v1/vector-stores/{id}/files). Other calls (GET/PUT/DELETE) are unchanged.

Testing

Verified against a live lightspeed-core deployment that was returning 429s on concurrent attaches: with the retry in place, bursty uploads complete instead of failing.

🤖 Generated with Claude Code

lightspeed-core bounds concurrent file uploads and vector store attaches with
per-endpoint semaphores (max_concurrent_file_uploads /
max_concurrent_vector_store_attaches) and rejects excess requests with 429 Too
Many Requests rather than queuing them. Bursty notebook uploads therefore
failed intermittently.

Add a fetchWithRetry helper in VectorStoresOperator that retries on 429,
honoring the Retry-After header when present and otherwise backing off
exponentially (capped, up to 8 attempts). Use it for the file upload
(POST /v1/files) and vector store attach (POST /v1/vector-stores/{id}/files)
calls, the two endpoints subject to those limits.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
@rhdh-gh-app

rhdh-gh-app Bot commented Sep 15, 2026

Copy link
Copy Markdown

Changed Packages

Package Name Package Path Changeset Bump Current Version
@red-hat-developer-hub/backstage-plugin-intelligent-assistant-backend workspaces/intelligent-assistant/plugins/intelligent-assistant-backend patch v5.2.0

@codecov

codecov Bot commented Sep 15, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.11765% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 63.13%. Comparing base (ef6ab36) to head (fea16af).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4786      +/-   ##
==========================================
- Coverage   63.60%   63.13%   -0.47%     
==========================================
  Files        2685     2655      -30     
  Lines      107077   105862    -1215     
  Branches    30010    29646     -364     
==========================================
- Hits        68101    66841    -1260     
- Misses      37165    37212      +47     
+ Partials     1811     1809       -2     
Flag Coverage Δ *Carryforward flag
adoption-insights 84.77% <ø> (ø) Carriedforward from 2ba5eff
ai-integrations 78.54% <ø> (-4.10%) ⬇️ Carriedforward from 2ba5eff
app-defaults 54.82% <ø> (ø) Carriedforward from 2ba5eff
augment 46.67% <ø> (ø) Carriedforward from 2ba5eff
boost 84.04% <ø> (-0.93%) ⬇️ Carriedforward from 2ba5eff
bulk-import 73.12% <ø> (ø) Carriedforward from 2ba5eff
cost-management 13.53% <ø> (ø) Carriedforward from 2ba5eff
dcm 73.47% <ø> (ø) Carriedforward from 2ba5eff
e2e-adoption-insights 60.00% <ø> (ø) Carriedforward from 2ba5eff
e2e-extensions 62.31% <ø> (ø) Carriedforward from 2ba5eff
e2e-global-header 51.82% <ø> (ø) Carriedforward from 2ba5eff
e2e-homepage 61.11% <ø> (ø) Carriedforward from 2ba5eff
e2e-intelligent-assistant 46.01% <ø> (ø) Carriedforward from 2ba5eff
e2e-orchestrator 49.49% <ø> (ø) Carriedforward from 2ba5eff
e2e-orchestrator-plugin 49.48% <ø> (ø) Carriedforward from 2ba5eff
e2e-quickstart 55.21% <ø> (ø) Carriedforward from 2ba5eff
e2e-scorecard 50.00% <ø> (ø) Carriedforward from 2ba5eff
e2e-theme 16.36% <ø> (ø) Carriedforward from 2ba5eff
extensions 58.30% <ø> (ø) Carriedforward from 2ba5eff
global-floating-action-button 71.18% <ø> (ø) Carriedforward from 2ba5eff
global-header 67.78% <ø> (+0.02%) ⬆️ Carriedforward from 2ba5eff
homepage 48.57% <ø> (-6.59%) ⬇️ Carriedforward from 2ba5eff
install-dynamic-plugins 71.77% <ø> (ø) Carriedforward from 2ba5eff
intelligent-assistant 78.10% <94.11%> (+0.06%) ⬆️
konflux 91.98% <ø> (ø) Carriedforward from 2ba5eff
lightspeed 69.02% <ø> (ø) Carriedforward from 2ba5eff
mcp-integrations 84.46% <ø> (ø) Carriedforward from 2ba5eff
orchestrator 77.32% <ø> (-0.37%) ⬇️ Carriedforward from 2ba5eff
quickstart 63.74% <ø> (ø) Carriedforward from 2ba5eff
sandbox 79.56% <ø> (ø) Carriedforward from 2ba5eff
scorecard 88.44% <ø> (-0.28%) ⬇️ Carriedforward from 2ba5eff
theme 87.91% <ø> (-0.03%) ⬇️ Carriedforward from 2ba5eff
translations 5.12% <ø> (ø) Carriedforward from 2ba5eff
x2a 78.44% <ø> (ø) Carriedforward from 2ba5eff

*This pull request uses carry forward flags. Click here to find out more.


Continue to review full report in Codecov by Harness.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update ef6ab36...fea16af. Read the comment docs.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@JslYoon

JslYoon commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

/fs-review

@fullsend-ai-review

fullsend-ai-review Bot commented Sep 16, 2026

Copy link
Copy Markdown

🤖 Finished Review · ✅ Success · Started 2:40 PM UTC · Completed 2:56 PM UTC

Commit: 35cf4d8 · View workflow run →

Runtime: claude · Model: opus → claude-opus-4-6 · Cost: $2.88

@fullsend-ai-review

Copy link
Copy Markdown

Review

Findings

Medium

  • [resource-leak] workspaces/intelligent-assistant/plugins/intelligent-assistant-backend/src/service/notebooks/VectorStoresOperator.ts:155 — When fetchWithRetry receives a 429 response and retries, the response body is never consumed or cancelled. On Node.js with undici-backed fetch, an unconsumed response body prevents the underlying TCP socket from being returned to the connection pool until the Response object is garbage-collected. Over up to 8 retries, sockets may remain stuck outside the pool, and under the concurrent batch upload scenario this PR targets, this could compound across parallel calls.
    Remediation: Add await response.body?.cancel(); before attempt += 1; to release the socket immediately.

Low

  • [design] workspaces/intelligent-assistant/plugins/intelligent-assistant-backend/src/service/notebooks/VectorStoresOperator.ts:164 — The exponential backoff formula 2 ** attempt * 250 is fully deterministic with no jitter. When multiple concurrent requests are rejected with 429 simultaneously, they will all compute the identical retry delay and retry at the same instant, potentially recreating contention.
    Remediation: Math.min(2 ** attempt * 250 + Math.random() * 250, 5000)

  • [edge-case] workspaces/intelligent-assistant/plugins/intelligent-assistant-backend/src/service/notebooks/VectorStoresOperator.ts:163 — The Retry-After header value is used as retryAfter * 1000 with no upper-bound cap. The exponential backoff branch correctly caps at 5000ms, but the Retry-After branch does not. A misconfigured upstream or proxy could send a large value, causing the method to block for an extended period.
    Remediation: Math.min(retryAfter * 1000, 30000)

  • [test-adequacy] workspaces/intelligent-assistant/plugins/intelligent-assistant-backend/src/service/notebooks/VectorStoresOperator.ts — No unit tests are added for fetchWithRetry. The method has multiple distinct code paths (immediate success, 429 with Retry-After header, 429 without Retry-After header, maxRetries exhaustion, non-429 error pass-through) that are all untested.

@fullsend-ai-review fullsend-ai-review 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.

See the review comment for full details.

@fullsend-ai-review fullsend-ai-review Bot added the requires-manual-review Review requires human judgment label Sep 16, 2026

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

Can we add some tests for this as well? Simulating the 429

Comment on lines +153 to +154
let attempt = 0;
for (;;) {

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.

Why not have a traditional for loop over attempt instead of infinitely loop and do extra work?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good call. Reworked it into a traditional for (let attempt = 0; attempt <= maxRetries; attempt++) loop that returns as soon as the request succeeds or is non-429, so there's no infinite loop or extra work. Done in 5c3faf5.

Add a VectorStoresOperator.test.ts suite exercising fetchWithRetry through the
public vector store attach method: first-try success, retry-then-succeed,
Retry-After precedence, exponential fallback, jitter, the 5s cap, retry
exhaustion, and non-429 responses not being retried. Fetch is mocked and fake
timers skip the real backoff sleeps.

Also harden fetchWithRetry:
- Cancel the 429 response body before backing off so the undici socket returns
  to the connection pool immediately instead of waiting for GC, which otherwise
  compounds across retries under the bursty concurrent uploads this targets.
- Add random jitter to the exponential backoff so several requests rejected at
  once don't recompute the same delay and retry in lockstep.

Assisted-by: Claude Opus 4.8

Co-Authored-By: Claude Opus 4.8 <[email protected]>
JslYoon and others added 2 commits September 16, 2026 22:44
Replace the `for (;;)` retry loop in fetchWithRetry with a bounded
`for (let attempt = 0; attempt <= maxRetries; attempt++)` so the iteration
limit is visible in the loop header rather than relying solely on the in-body
guard. Behaviour is unchanged: same backoff delays, same number of attempts,
same log text. A trailing throw after the loop is unreachable and present only
to satisfy the Promise<Response> return type.

Assisted-by: Claude Opus 4.8

Co-Authored-By: Claude Opus 4.8 <[email protected]>
@JslYoon
JslYoon requested a review from Jdubrick September 17, 2026 02:50
JslYoon and others added 2 commits September 17, 2026 23:06
…n in 429 test

The "does not retry a non-429 error response" test used
`await expect(attach()).rejects.toThrow()`. That matcher formats the
rejected error's stack, which source-map-support remaps by quick-sorting
the compiled bundle's mappings. Under CI's tighter stack limit that
quick-sort recurses past the maximum call stack and the test fails with
`RangeError: Maximum call stack size exceeded`, even though the code under
test is fine (the test passes locally).

Capture the rejection with `.catch(err => err)` and assert
`toBeInstanceOf(Error)` instead, matching the existing pattern in the
"gives up after maxRetries" test. This asserts the same behavior without
forcing stack symbolication.

Signed-off-by: Lucas <[email protected]>
Assisted-by: Claude Opus 4.8
Co-Authored-By: Claude Opus 4.8 <[email protected]>
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants