Skip to content

Fix podcast semantic indexing rate-limit handling#809

Merged
kentcdodds merged 5 commits into
mainfrom
cursor/fix-indexing-job-c7b0
Jun 18, 2026
Merged

Fix podcast semantic indexing rate-limit handling#809
kentcdodds merged 5 commits into
mainfrom
cursor/fix-indexing-job-c7b0

Conversation

@kentcdodds

@kentcdodds kentcdodds commented Jun 18, 2026

Copy link
Copy Markdown
Owner

Summary

  • Fail podcast indexing before destructive mutations when Simplecast returns incomplete/rate-limited data.
  • Make Simplecast retries more patient and default episode detail fetching to sequential requests.
  • Use current Vectorize v2 endpoints only; removed old endpoint fallbacks, old endpoint mocks, and fallback tests.
  • Add backend regression tests for Simplecast partial fetches and Vectorize delete behavior.
  • Addressed CodeRabbit review feedback for Simplecast 429 response shape and Vectorize v2 test coverage.

Testing

  • npm run test:backend --workspace kentcdodds.com -- tests/__tests__/semantic-search-cloudflare-vectorize.test.ts tests/__tests__/semantic-search-index-podcasts.test.ts mocks/__tests__/cloudflare.test.ts
  • npm run typecheck --workspace kentcdodds.com
  • npm run lint --workspace kentcdodds.com
  • ./node_modules/.bin/oxlint other/semantic-search/cloudflare.ts services/site/app/utils/vectorize.server.ts services/site/mocks/cloudflare.ts services/site/tests/__tests__/semantic-search-cloudflare-vectorize.test.ts
  • rg "isLegacyVectorizeError|deprecated-v1|vectorize/indexes|Vectorize.*legacy|legacy.*Vectorize" --glob '*.{ts,tsx}'

CI / review loop

  • Previous loop was green; rerunning after v2-only cleanup.
Open in Web Open in Cursor 

Summary by CodeRabbit

  • Bug Fixes

    • Cloudflare Vectorize now uses the v2 insert/upsert and delete_by_ids endpoints directly, removing legacy fallback/retry behavior on v2 errors.
    • Simplecast requests that hit rate limits (429) now fail fast with clearer errors to prevent partial/empty manifests.
  • Enhancements

    • Improved HTTP pacing using async delays, with configurable retry timing and episode-detail concurrency/spacing.
    • Updated the podcast module to only run when it’s the process entrypoint.
  • Tests

    • Added Vitest coverage for v2 delete_by_ids request details and ensuring non-retry on non-legacy errors.
    • Added Simplecast 429 rejection scenarios for seasons, episode lists, and episode details.

@coderabbitai

coderabbitai Bot commented Jun 18, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Removes legacy Cloudflare Vectorize v1 fallback logic, migrating write and delete operations to v2-only endpoints with updated mocks and tests. Hardens Simplecast episode fetching with configurable retry/concurrency/spacing, strict 429 error propagation, and utility helpers for environment-based configuration and async delay handling.

Changes

Cloudflare Vectorize v2-only endpoint migration

Layer / File(s) Summary
Vectorize write and delete operations v2-only
other/semantic-search/cloudflare.ts, services/site/app/utils/vectorize.server.ts
vectorizeWriteNdjson and vectorizeDeleteByIds remove try/catch blocks and legacy fallback logic, calling v2 endpoints directly without error-detection-based retry or fallback to v1 routes.
Mock Cloudflare Vectorize v2 routing
services/site/mocks/cloudflare.ts
Mock HTTP routing adds/ensures v2 query, insert, upsert, and delete_by_ids endpoint handlers; removes legacy v1 route registrations so those paths are no longer intercepted.
Vectorize delete tests
services/site/tests/__tests__/semantic-search-cloudflare-vectorize.test.ts
Vitest coverage for vectorizeDeleteByIds verifies the v2 delete_by_ids endpoint is called with correct URL and request body, and that non-legacy HTTP 400 errors are surfaced without fallback retry.

Simplecast episode fetching robustness

Layer / File(s) Summary
Utility helpers and main module guard
other/semantic-search/index-podcasts.ts
Adds pathToFileURL import, getNumberEnv for env-based numeric config, and sleep(ms) helper; replaces raw setTimeout with await sleep() in 429 and 5xx retry-backoff paths; wraps main() in isMainModule() guard.
Strict 429 error propagation and configurable episode fetching
other/semantic-search/index-podcasts.ts, services/site/tests/__tests__/semantic-search-index-podcasts.test.ts
Changes SimplecastTooManyRequests to structured 429 response; adds requireCompleteSimplecastResponse to throw on 429 at seasons, episodes-list, and episode-detail phases; rewrites episode-detail fetching with env-derived concurrency and per-request spacing; tests verify rejection messages at each rate-limit scenario.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • kentcdodds/kentcdodds.com#603: Aligns Cloudflare Vectorize client behavior with v2 API surface; both PRs remove legacy v1 fallback logic and update mocks to v2-only endpoint routing.
  • kentcdodds/kentcdodds.com#670: Both PRs change Simplecast 429 handling to propagate errors rather than silently skip; relates to the retry/backoff refactoring in this PR.

Poem

🐇 No more fallbacks, the v2 path is clear,
With sleep and getNumberEnv standing near.
A 429 arrives—we throw with pride!
The rabbit refactored, with nowhere to hide.
Each test mocks a fetch, each error's caught tight—
The semantic search now handles it right! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.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 title 'Fix podcast semantic indexing rate-limit handling' clearly and concisely summarizes the main change: addressing rate-limit handling in podcast semantic indexing functionality, which aligns with the PR objectives and file-level changes throughout the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ 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 cursor/fix-indexing-job-c7b0

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.

@kentcdodds kentcdodds marked this pull request as ready for review June 18, 2026 13:00

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
other/semantic-search/index-podcasts.ts (1)

238-238: ⚠️ Potential issue | 🔴 Critical

Fix isTooManyRequests type guard to match actual Simplecast API response shape.

The local type SimplecastTooManyRequests redefines the type as { too_many_requests: true } but the actual API contract in services/site/types/simplecast.d.ts specifies { status: 429, href: null, error_message: string, error: string }. The isTooManyRequests guard checks for the wrong shape and will fail to detect rate-limited responses matching the declared type. This creates a functional correctness bug: if a 429 response reaches requireCompleteSimplecastResponse, the type guard won't catch it, allowing invalid data through.

Align the local SimplecastTooManyRequests type with the declared API type or adjust the guard to check the correct response shape.

🤖 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 `@other/semantic-search/index-podcasts.ts` at line 238, The
SimplecastTooManyRequests type is incorrectly defined as { too_many_requests:
true } but the actual Simplecast API contract returns { status: 429, href: null,
error_message: string, error: string }. Update the SimplecastTooManyRequests
type definition to match the actual API response shape, and ensure the
isTooManyRequests type guard checks against the correct properties (status field
equal to 429) rather than the non-existent too_many_requests property. This will
allow the type guard to properly detect rate-limited 429 responses from the API.
🧹 Nitpick comments (3)
services/site/tests/__tests__/semantic-search-cloudflare-vectorize.test.ts (1)

37-50: ⚡ Quick win

Add a regression test for the legacy fallback branch.

Please add a case where the first (v2) call throws a legacy-signature error and assert the second call targets /vectorize/indexes/.../delete_by_ids. That directly protects the new fallback gate behavior in this PR.

Proposed test shape
+test('vectorizeDeleteByIds falls back to legacy endpoint for legacy-signature errors', async () => {
+  const fetchMock = vi.spyOn(globalThis, 'fetch')
+    .mockResolvedValueOnce(new Response('not found', { status: 404 }))
+    .mockResolvedValueOnce(
+      new Response(JSON.stringify({ success: true, result: { mutationId: 'legacy-m1' } })),
+    )
+
+  try {
+    await vectorizeDeleteByIds(vectorizeConfig)
+    expect(fetchMock).toHaveBeenCalledTimes(2)
+    expect(String(fetchMock.mock.calls[1]![0])).toContain(
+      '/vectorize/indexes/search-index/delete_by_ids',
+    )
+  } finally {
+    fetchMock.mockRestore()
+  }
+})
🤖 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 `@services/site/tests/__tests__/semantic-search-cloudflare-vectorize.test.ts`
around lines 37 - 50, Add a new test case after the existing test
`vectorizeDeleteByIds does not mask non-legacy v2 errors` to specifically cover
the legacy fallback behavior. This test should mock the fetch function to first
throw a legacy-signature error when the v2 endpoint is called, then mock it to
succeed on the second call. Assert that when the legacy-signature error is
caught, the function retries with a call to the legacy endpoint at
`/vectorize/indexes/.../delete_by_ids`. This regression test will directly
protect the new fallback gate behavior in the vectorizeDeleteByIds function by
ensuring the legacy fallback path is exercised and validated.
other/semantic-search/index-podcasts.ts (1)

280-395: 💤 Low value

Consider moving sleep before the early return to ensure consistent pacing.

At lines 377-379, when !epJson.is_published the function returns null immediately without sleeping. If multiple consecutive episodes have mismatched publish status between the list and detail responses, this could cause burst requests. Given the pre-filtering at line 349-351 this is unlikely in practice, but moving the sleep before the early return would ensure consistent rate limiting regardless of episode state.

 				const epJson = requireCompleteSimplecastResponse(
 					result.json,
 					`simplecast episode ${e.id}`,
 				)
-				if (!epJson.is_published) return null
 				await sleep(episodeDetailSpacingMs)
+				if (!epJson.is_published) return null
 				return epJson
🤖 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 `@other/semantic-search/index-podcasts.ts` around lines 280 - 395, In the
fetchSimplecastEpisodes function, within the async callback passed to
mapWithConcurrency, the sleep call using episodeDetailSpacingMs is placed after
the early return check for unpublished episodes. Move the await
sleep(episodeDetailSpacingMs) statement to execute before the if
(!epJson.is_published) return null check to ensure consistent rate limiting and
pacing regardless of whether an episode is published or not. This prevents
potential burst requests when multiple consecutive episodes are unpublished.
services/site/tests/__tests__/semantic-search-index-podcasts.test.ts (1)

12-50: 💤 Low value

Consider adding a test for HTTP 200 with too_many_requests JSON body.

Both tests return HTTP 429 status, which triggers the retry exhaustion path in fetchJsonWithRetries before reaching JSON parsing. This means requireCompleteSimplecastResponse is not exercised. If Simplecast can return HTTP 200 with a { too_many_requests: true } body (as the guard function suggests), adding a test for that scenario would ensure the guard works correctly.

test('fetchSimplecastEpisodes fails when seasons returns too_many_requests in body', async () => {
  using _ignoredEnv = setEnv({
    SIMPLECAST_KEY: 'simplecast-key',
    CHATS_WITH_KENT_PODCAST_ID: 'podcast-id',
    SIMPLECAST_MAX_RETRIES: '0',
    SIMPLECAST_BASE_DELAY_MS: '0',
  })
  const fetchMock = vi
    .spyOn(globalThis, 'fetch')
    .mockImplementation(async () => {
      // HTTP 200 but rate-limited JSON body
      return jsonResponse({ too_many_requests: true }, { status: 200 })
    })

  try {
    await expect(fetchSimplecastEpisodes()).rejects.toThrow(
      'simplecast seasons returned Simplecast too_many_requests',
    )
  } finally {
    fetchMock.mockRestore()
  }
})

Also applies to: 52-97

🤖 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 `@services/site/tests/__tests__/semantic-search-index-podcasts.test.ts` around
lines 12 - 50, Add a new test case called 'fetchSimplecastEpisodes fails when
seasons returns too_many_requests in body' to cover the scenario where the
Simplecast API returns HTTP 200 status but with { too_many_requests: true } in
the JSON body. This test ensures the requireCompleteSimplecastResponse guard
function is properly exercised. Set up the test similarly to the existing test
by mocking fetch to return jsonResponse({ too_many_requests: true }, { status:
200 }), configure the same environment variables (SIMPLECAST_KEY,
CHATS_WITH_KENT_PODCAST_ID, SIMPLECAST_MAX_RETRIES: '0',
SIMPLECAST_BASE_DELAY_MS: '0'), and expect fetchSimplecastEpisodes() to reject
with an error message containing 'simplecast seasons returned Simplecast
too_many_requests'.
🤖 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.

Outside diff comments:
In `@other/semantic-search/index-podcasts.ts`:
- Line 238: The SimplecastTooManyRequests type is incorrectly defined as {
too_many_requests: true } but the actual Simplecast API contract returns {
status: 429, href: null, error_message: string, error: string }. Update the
SimplecastTooManyRequests type definition to match the actual API response
shape, and ensure the isTooManyRequests type guard checks against the correct
properties (status field equal to 429) rather than the non-existent
too_many_requests property. This will allow the type guard to properly detect
rate-limited 429 responses from the API.

---

Nitpick comments:
In `@other/semantic-search/index-podcasts.ts`:
- Around line 280-395: In the fetchSimplecastEpisodes function, within the async
callback passed to mapWithConcurrency, the sleep call using
episodeDetailSpacingMs is placed after the early return check for unpublished
episodes. Move the await sleep(episodeDetailSpacingMs) statement to execute
before the if (!epJson.is_published) return null check to ensure consistent rate
limiting and pacing regardless of whether an episode is published or not. This
prevents potential burst requests when multiple consecutive episodes are
unpublished.

In `@services/site/tests/__tests__/semantic-search-cloudflare-vectorize.test.ts`:
- Around line 37-50: Add a new test case after the existing test
`vectorizeDeleteByIds does not mask non-legacy v2 errors` to specifically cover
the legacy fallback behavior. This test should mock the fetch function to first
throw a legacy-signature error when the v2 endpoint is called, then mock it to
succeed on the second call. Assert that when the legacy-signature error is
caught, the function retries with a call to the legacy endpoint at
`/vectorize/indexes/.../delete_by_ids`. This regression test will directly
protect the new fallback gate behavior in the vectorizeDeleteByIds function by
ensuring the legacy fallback path is exercised and validated.

In `@services/site/tests/__tests__/semantic-search-index-podcasts.test.ts`:
- Around line 12-50: Add a new test case called 'fetchSimplecastEpisodes fails
when seasons returns too_many_requests in body' to cover the scenario where the
Simplecast API returns HTTP 200 status but with { too_many_requests: true } in
the JSON body. This test ensures the requireCompleteSimplecastResponse guard
function is properly exercised. Set up the test similarly to the existing test
by mocking fetch to return jsonResponse({ too_many_requests: true }, { status:
200 }), configure the same environment variables (SIMPLECAST_KEY,
CHATS_WITH_KENT_PODCAST_ID, SIMPLECAST_MAX_RETRIES: '0',
SIMPLECAST_BASE_DELAY_MS: '0'), and expect fetchSimplecastEpisodes() to reject
with an error message containing 'simplecast seasons returned Simplecast
too_many_requests'.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 378399bb-7b9a-48cd-8ca7-02c2054ca05b

📥 Commits

Reviewing files that changed from the base of the PR and between 3c464d8 and 24dbf73.

📒 Files selected for processing (4)
  • other/semantic-search/cloudflare.ts
  • other/semantic-search/index-podcasts.ts
  • services/site/tests/__tests__/semantic-search-cloudflare-vectorize.test.ts
  • services/site/tests/__tests__/semantic-search-index-podcasts.test.ts

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

🧹 Nitpick comments (1)
services/site/tests/__tests__/semantic-search-cloudflare-vectorize.test.ts (1)

64-67: ⚡ Quick win

Assert the first call hits v2 before fallback.

This test only verifies the second call URL. Please also assert call 1 is the v2 endpoint so regressions that bypass v2 are caught.

Suggested test hardening
 	try {
 		await vectorizeDeleteByIds(vectorizeConfig)
 		expect(fetchMock).toHaveBeenCalledTimes(2)
+		expect(String(fetchMock.mock.calls[0]?.[0])).toBe(
+			'https://api.cloudflare.com/client/v4/accounts/account-id/vectorize/v2/indexes/search-index/delete_by_ids',
+		)
 		expect(String(fetchMock.mock.calls[1]?.[0])).toBe(
 			'https://api.cloudflare.com/client/v4/accounts/account-id/vectorize/indexes/search-index/delete_by_ids',
 		)
 	} finally {
🤖 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 `@services/site/tests/__tests__/semantic-search-cloudflare-vectorize.test.ts`
around lines 64 - 67, The test currently only asserts that the second fetch call
(fetchMock.mock.calls[1]) hits the fallback endpoint URL. Add an additional
assertion before the existing one to verify that the first fetch call
(fetchMock.mock.calls[0]) successfully hits the v2 endpoint URL, ensuring that
the v2 endpoint is attempted before any fallback occurs and preventing
regressions that might bypass v2.
🤖 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 `@services/site/tests/__tests__/semantic-search-cloudflare-vectorize.test.ts`:
- Around line 64-67: The test currently only asserts that the second fetch call
(fetchMock.mock.calls[1]) hits the fallback endpoint URL. Add an additional
assertion before the existing one to verify that the first fetch call
(fetchMock.mock.calls[0]) successfully hits the v2 endpoint URL, ensuring that
the v2 endpoint is attempted before any fallback occurs and preventing
regressions that might bypass v2.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9edd0d55-328a-4aff-9d9c-05ececf692cc

📥 Commits

Reviewing files that changed from the base of the PR and between 24dbf73 and 9fed156.

📒 Files selected for processing (3)
  • other/semantic-search/index-podcasts.ts
  • services/site/tests/__tests__/semantic-search-cloudflare-vectorize.test.ts
  • services/site/tests/__tests__/semantic-search-index-podcasts.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • other/semantic-search/index-podcasts.ts

@kentcdodds kentcdodds merged commit 09db997 into main Jun 18, 2026
11 checks passed
@kentcdodds kentcdodds deleted the cursor/fix-indexing-job-c7b0 branch June 18, 2026 18:26
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.

2 participants