Skip to content

Invalidate query cache on disconnect to fix stale auth tokens#839

Merged
threepointone merged 6 commits intomainfrom
invalidate-cache-on-disconnect
Feb 4, 2026
Merged

Invalidate query cache on disconnect to fix stale auth tokens#839
threepointone merged 6 commits intomainfrom
invalidate-cache-on-disconnect

Conversation

@whoiskatrin
Copy link
Contributor

Summary

  • Invalidates the query cache entry when the WebSocket connection closes
  • Ensures async query functions (e.g., for auth tokens) are re-executed on reconnect
  • Eliminates auth failures caused by reusing stale cached tokens after disconnect

Problem

When using useAgent with an async query function for authentication:

query: async () => ({ token: await getIdToken(true) })

The cached query result was reused on reconnect even if the token had expired. Reconnects don't change the cache key or wait for TTL expiration, so stale tokens were sent to the server causing auth failures.

Solution

By calling deleteCacheEntry(cacheKey) in the onClose handler, we invalidate the cache on disconnect. When the socket reconnects, the async query will be re-executed to fetch a fresh token.

This is simpler than adding new API options (queryCacheMode, alwaysRefreshQuery, etc.) and provides the correct behavior by default - reconnects should always use fresh auth data.

Fixes #836

…nnect

When using async query functions with useAgent (e.g., for auth tokens),
the cached result was reused on reconnect even if the token had expired.
This caused auth failures because reconnects don't trigger cache key
changes or wait for TTL expiration.

By invalidating the cache entry in onClose, we ensure the async query
is always re-executed on reconnect, fetching fresh tokens.

Fixes #836
@changeset-bot
Copy link

changeset-bot bot commented Feb 4, 2026

🦋 Changeset detected

Latest commit: 8d3bdb8

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
agents Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Invalidate query cache on disconnect to fix stale auth tokens
@claude
Copy link

claude bot commented Feb 4, 2026

Claude Code Review

Issue: Race condition when resolvedQuery is undefined during reconnection.

In react.tsx:560-567, the onClose handler invalidates the cache and sets awaitingQueryRefresh = true for async queries to prevent reconnection until fresh query params are ready. However, there's a potential issue:

The Problem:

  1. onClose sets awaitingQueryRefresh = true and invalidates cache
  2. Socket is disabled via socketEnabled = !awaitingQueryRefresh && ... (line 453)
  3. The effect at lines 400-404 waits for resolvedQuery !== undefined to re-enable socket
  4. But: If use(queryPromise!) at line 373 suspends waiting for the new query, resolvedQuery remains from the previous render
  5. This means the effect at 400-404 might fire immediately with the stale resolvedQuery, re-enabling the socket before the new query resolves

The Race:

onClose → awaitingQueryRefresh=true, invalidate cache
  ↓
Component re-renders with awaitingQueryRefresh=true
  ↓
use(queryPromise!) may suspend OR return old cached value in this render cycle
  ↓  
Effect sees awaitingQueryRefresh=true && resolvedQuery=<stale value>
  ↓
Socket re-enables with stale auth token

Suggested Fix:
The effect should verify that resolvedQuery is actually fresh, not from before the disconnect. One approach:

// Track invalidation timestamp
const queryInvalidatedAtRef = useRef<number>(0);

// In onClose:
queryInvalidatedAtRef.current = Date.now();
deleteCacheEntry(cacheKeyRef.current);

// In the effect - only re-enable if query was fetched after invalidation:
useEffect(() => {
  if (awaitingQueryRefresh && resolvedQuery !== undefined) {
    const entry = getCacheEntry(cacheKey);
    if (entry && entry.createdAt > queryInvalidatedAtRef.current) {
      setAwaitingQueryRefresh(false);
    }
  }
}, [awaitingQueryRefresh, resolvedQuery, cacheKey]);

This requires tracking cache entry creation time (not currently stored).

Alternative: Key the effect off cacheInvalidatedAt instead:

useEffect(() => {
  if (awaitingQueryRefresh) {
    setAwaitingQueryRefresh(false);
  }
}, [cacheInvalidatedAt]);

Test Coverage: The test at cache-invalidation.test.tsx:169-198 pre-populates cache before connection, so doesn't exercise the actual async query re-execution path. A test that triggers real async query execution on reconnect would catch this race.


Otherwise solid implementation - good use of refs for cache key capture, comprehensive test coverage, and the core fix (cache invalidation on disconnect) is sound.

agents-git-bot bot pushed a commit to cloudflare/cloudflare-docs that referenced this pull request Feb 4, 2026
Updates documentation to reflect that useAgent automatically invalidates
the query cache when the WebSocket connection closes. This ensures that
async query functions (e.g., for fetching auth tokens) are re-executed
on reconnect, providing fresh tokens after network interruptions.

Changes:
- Added missing query, queryDeps, cacheTtl, and other parameters to UseAgentOptions type
- Added dedicated section on authentication with async query functions
- Added client-side authentication example in calling-agents.mdx
- Documented automatic token refresh behavior on reconnection

Related to cloudflare/agents#839
@pkg-pr-new
Copy link

pkg-pr-new bot commented Feb 4, 2026

Open in StackBlitz

npm i https://pkg.pr.new/cloudflare/agents@839

commit: 8d3bdb8

- Add unit tests in cache-ttl.test.ts for:
  - Re-caching after deletion (reconnect scenario)
  - Graceful handling of non-existent key deletion
  - Cache isolation by key (different agents/instances)

- Add integration tests in cache-invalidation.test.tsx for:
  - Verifying cache is not affected by static query objects
  - Cache key generation with different namespaces and deps
  - onClose callback is called on disconnect
  - Cache is invalidated before user's onClose callback
  - Successful reconnection after cache invalidation
  - Cache isolation when multiple components use different agent instances
Address two issues with cache invalidation on disconnect:

1. Cache key ref timing: The cache key was captured in the onClose
   closure at render time, which could cause the wrong cache entry to
   be invalidated if the component re-rendered with different props
   before onClose fired. Fix: use a ref (cacheKeyRef) that's updated
   via useEffect when cacheKey changes.

2. Re-render trigger: Just deleting the cache entry doesn't trigger a
   re-render, so PartySocket would reconnect with the old resolved
   query value. Fix: call setCacheInvalidatedAt(Date.now()) to bump
   state, which causes queryPromise useMemo to recompute and fetch
   fresh tokens before reconnecting.

Test improvements:
- Export createCacheKey in _testUtils so tests use the exact same
  key generation as useAgent internally
- Update tests to use createCacheKey instead of hardcoded JSON
- Add test for the ref timing scenario (name changes before disconnect)
Update cacheKeyRef synchronously during render instead of in useEffect.
The useEffect runs asynchronously after render, creating a window where
onClose could fire before the effect updates the ref, causing the wrong
cache entry to be invalidated.

Refs can be safely updated during render when storing derived values.
Comment on lines 546 to 553
// Invalidate the query cache and trigger re-render so reconnection will
// re-run the async query with fresh tokens. We must both:
// 1. Delete the cache entry so getCacheEntry() returns undefined
// 2. Bump cacheInvalidatedAt to trigger useMemo to recompute queryPromise
// Without the state bump, the component keeps the old resolved query value
// and PartySocket would reconnect with stale query params.
deleteCacheEntry(cacheKeyRef.current);
setCacheInvalidatedAt(Date.now());

Choose a reason for hiding this comment

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

Main remaining concern is a race - the socket’s reconnect loop can fire with the old URL/query before the re‑render (triggered by cache invalidation + state bump) completes. The only obvious way I see to eliminate the race is to pause reconnects while the query refresh happens.

Looks like we can use enabled (UsePartySocketOptions) to handle this? So a pattern like:

  • onClose: setEnabled(false) + invalidate cache + bump state
  • after query resolves / re-render: setEnabled(true)

Idea being to guarantee no reconnect attempt happens before the new query is ready.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

yes, that's valid, we might just do that in the end

@aurewill-gavel
Copy link

aurewill-gavel commented Feb 4, 2026

Could we add an integration test where the query returns a different token on each invocation, then assert which token appears on the first reconnect? Probably makes sense to make it intentionally race‑y by:

  1. Delaying the async query and
  2. Forcing fast reconnects (set minReconnectionDelay/maxReconnectionDelay low).

Then assert which token the first reconnect used:

let calls = 0;
query: async () => { await delay(100); return { token: `t-${++calls}` } };

force reconnect();
assert first reconnect used "t-2" (not "t-1")

Without that timing pressure, the test could pass even if a race still exists, simply because the reconnect happens after the refresh render completes. This would validate the behavior the PR claims (fresh token on reconnect), beyond just cache deletion.

Prevents race where PartySocket reconnects with stale query params
before React re-renders with fresh tokens.
@whoiskatrin
Copy link
Contributor Author

@aurewill-gavel give the latest version a go, please, if there will be more gaps, we will revisit

@aurewill-gavel
Copy link

@whoiskatrin Logic looks sound. That test I mentioned would be ideal but obv your decision whether or not to add it.

@threepointone
Copy link
Contributor

let's land this, I suspect suspense might help out here a bit. and because it's not an api change, we can revisit and make it stronger if we need to.

@threepointone threepointone merged commit 68916bf into main Feb 4, 2026
9 checks passed
@threepointone threepointone deleted the invalidate-cache-on-disconnect branch February 4, 2026 18:21
@github-actions github-actions bot mentioned this pull request Feb 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.

Feature request: always re‑run async query on connect/reconnect (awaited)

3 participants