Skip to content

fix(ai): hold stale prompts on a cooldown after a failed refetch - #4775

Open
posthog[bot] wants to merge 3 commits into
mainfrom
posthog-self-driving/fixllma-stop-a-429-turning-prompt-d04999
Open

fix(ai): hold stale prompts on a cooldown after a failed refetch#4775
posthog[bot] wants to merge 3 commits into
mainfrom
posthog-self-driving/fixllma-stop-a-429-turning-prompt-d04999

Conversation

@posthog

@posthog posthog Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Problem

  • Prompt management users who hit the fetch rate limit stay stuck there. The SDK is what keeps them there.
  • On a failed refetch, getInternal falls back to the stale cache entry but never touches its fetchedAt. The entry stays expired, so the next prompts.get() treats it as stale and goes back to the network.
  • A normally-cached read becomes a per-call network request. Once a client is throttled, it keeps generating the load that holds it against the limit, so the limit never clears.
  • The server's tightest prompt limit is per-minute, so a client polling a set of prompts recovers only by chance.

Changes

  • A failed refetch now opens a cooldown on the cache entry. Later calls inside it are served from cache and make no request, so a throttled client drops back to one attempt per cooldown instead of one per call.
  • On a 429, the cooldown length follows the server's Retry-After header, clamped to one hour. Any other failure uses a 60 second default, which is one refill of the per-minute bucket.
  • Cooldown reads still report source: 'stale_cache', so callers that already branch on source see no change. The warning is still logged once, by the call that actually failed.
  • Mechanical: a new PromptFetchError carries Retry-After out of fetchPromptFromApi, and the three stale/fresh return paths share one readCacheEntry helper.

Before / after

Ten get() calls on one throttled prompt, against the built package:

requests sources
Before 10 stale_cache ×10
After 1 stale_cache ×10

The prompt text served to the caller is identical in both.

Release info Sub-libraries affected

Libraries affected

  • All of them
  • posthog-js (web)
  • posthog-js-lite (web lite)
  • posthog-node
  • posthog-react-native
  • @posthog/react-native-plugin
  • @posthog/react
  • @posthog/ai
  • @posthog/convex
  • @posthog/next
  • @posthog/nextjs-config
  • @posthog/nuxt
  • @posthog/openfeature-node-provider
  • @posthog/openfeature-web-provider
  • @posthog/rollup-plugin
  • @posthog/webpack-plugin
  • @posthog/types
  • @posthog/browser-common

Checklist

  • Tests for new code
  • Accounted for the impact of any changes across different platforms
  • Accounted for backwards compatibility of any changes (no breaking changes!)
  • Took care not to unnecessarily increase the bundle size

If releasing new changes

  • Ran pnpm changeset to generate a changeset file
Test strategy

Unit — three cases added to packages/ai/tests/prompts.test.ts, over the existing fake-timer setup:

  • no refetch during the cooldown after a failed refetch
  • a refetch once the cooldown expires, so the fix does not pin a stale prompt forever
  • a 429 cooldown that follows Retry-After past the 60 second default

Two of the three fail on main and pass here. The third passes on main by construction — it guards the fix against over-caching, which is the risk the fix introduces.

packages/ai suite: 883 passed, 1 skipped. turbo build lint --filter=@posthog/ai: clean.

End-to-end — a script against the built dist/index.mjs with a stubbed fetch returning 429 with a real Headers object. It produced the before/after table above and confirmed retryAfterSeconds: 30 was read off the header.

Edge cases considered — a Retry-After that is absent, non-numeric, zero, or negative falls back to the default; an oversized one is clamped to an hour so a bad header cannot pin the cache for a day. A successful refetch replaces the whole entry, which clears the cooldown. A cold cache with no entry is unchanged: there is nothing to serve, so it still fetches and falls through to fallback.

Note

Two things named in the report are deliberately left alone. The cache key is version ?? label, so the same prompt fetched with and without a label is two entries and two requests — that is two different server queries, not a bug, and merging them would change what a labeled fetch returns. Batch fetching is #4690. A throttled request also emits no $llm_prompt_fetched event, which leaves this load invisible in a team's own usage logs; that is server-side, in posthog/posthog, and needs its own change.

🤖 Agent context

Autonomy: Fully autonomous

  • Written by Claude Opus 5 in PostHog Desktop, from a self-driving inbox report. No human drove the work, so the PR is left unassigned for the owning team to triage.
  • The report suggested refreshing the stale entry's fetchedAt. I rejected that: it would make the entry claim an age it does not have, so a later read would report source: 'cache' for data that was never revalidated, and the TTL would silently become the backoff. A separate retryNotBefore field keeps fetchedAt honest and keeps the result stale_cache.
  • I also considered backing off when there is no cache entry at all. That path has nothing to serve, and holding a negative entry would change what fallback means, so I left it and kept the diff on the described symptom.
  • The Retry-After parser handles only the delta-seconds form. The HTTP-date form is legal but DRF does not send it, and accepting both would add a branch no caller reaches.

Created with PostHog Desktop from this inbox report.

A failed refetch fell back to the stale cache entry but left its fetchedAt
timestamp untouched. The entry stayed expired, so every later prompts.get()
call went back to the network. A rate-limited client turned a cached read into
a per-call request and held itself against the limit.

Set a cooldown on the entry when a refetch fails, and serve the stale entry
until it expires. On a 429 the cooldown follows the server's Retry-After
header, clamped to an hour.

Generated-By: PostHog Desktop
Task-Id: 00a804cf-4efc-4168-afce-b8b2f1ebff63
@posthog

posthog Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

The Unit tests check is red for a reason unrelated to this PR, and it is red on main too.

The failure is 8 cases in packages/browser/src/__tests__/utils/array-find-last-polyfill.test.ts, all ReferenceError: jest is not defined. That file calls jest.isolateModules, which the runner no longer provides.

Cause looks like an ordering problem between two commits already on main:

#4732 was written against Jest and landed after the migration without a rebase, so its jest references survived into a Vitest run.

I reproduced it on origin/main with this branch checked out. This PR touches only packages/ai and .changeset, nothing under packages/browser, and the @posthog/ai suite is green at 883 passed. I have not fixed the browser test here — it is outside this PR, and folding it in would put an unrelated browser change in an @posthog/ai release. It needs its own PR; I could not find one open.

@andrewm4894
andrewm4894 marked this pull request as ready for review September 4, 2026 10:47
@andrewm4894
andrewm4894 requested a review from a team as a code owner September 4, 2026 10:47
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 4, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-04T10:50:08.928620Z 69751db Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@andrewm4894
andrewm4894 requested a review from a team September 4, 2026 10:47
@dustinbyrne dustinbyrne self-assigned this Sep 4, 2026
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