Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/toolkit-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ With `validateRedirects`, it follows redirects by hand, sends the credential
only to origins the host trusts, and records the final origin for
`getValidatedResponseSource`. With a cache, a GET is looked up first (a stale
entry is revalidated with its `ETag`), a successful GET is stored, and a
successful write invalidates what it made stale.
successful write invalidates what it made stale. While the host's `fresh`
hook returns true, such as during a tool call that asked for fresh data, GETs
skip the lookup and their responses replace the cached entry.

The host passes in a `CredentialProvider` (the current credential, an optional
refresh ahead of expiry, and the 401 refresh), header rules that may reject a
Expand Down
31 changes: 31 additions & 0 deletions packages/toolkit-core/src/api-transport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,37 @@ describe("cache", () => {
expect(sent).toHaveLength(1);
});

it("skips the cache in a fresh scope and replaces the entry", async () => {
const cache = memoryCache();
const { fetch, sent } = scriptedFetch([
Response.json({ v: 1 }),
Response.json({ v: 2 }),
]);
let fresh = false;
const api = transport(fetch, { cache, fresh: () => fresh });
const stored = async (v: number) =>
await vi.waitFor(async () => {
const lookup = await cache.lookup({
url: ISSUES_URL,
requestHeaders: { authorization: "Bearer token-a" },
identity: "id-a",
});
expect(
lookup?.kind === "hit" ? await lookup.response.json() : undefined,
).toEqual({ v });
});

await api.request(ISSUES_URL);
await stored(1);
fresh = true;
expect(await (await api.request(ISSUES_URL)).json()).toEqual({ v: 2 });
await stored(2);
fresh = false;

expect(await (await api.request(ISSUES_URL)).json()).toEqual({ v: 2 });
expect(sent).toHaveLength(2);
});

it("revalidates a stale entry with its ETag", async () => {
const response = await transport(
scriptedFetch([new Response(null, { status: 304 })]).fetch,
Expand Down
11 changes: 10 additions & 1 deletion packages/toolkit-core/src/api-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,12 @@ export type ApiTransportOptions<C extends TransportCredential> = {
): Promise<Error | undefined>;
};
cache?: TransportCache;
/**
* Whether the current scope skips cache reads, such as a tool call with
* `fresh: true`. Hosts back it with request-scoped state like
* `AsyncLocalStorage`. The response is still stored, replacing the entry.
*/
fresh?(): boolean;
logger?: TransportLogger;
/** Wrap each request, including cache lookups, in a host span. */
span?(
Expand Down Expand Up @@ -663,7 +669,10 @@ export function createApiTransport<C extends TransportCredential>(
// A cached response has no validated origin, so discovery always
// goes to the network.
const lookup =
cache && method === "GET" && !options.validateRedirects
cache &&
method === "GET" &&
!options.validateRedirects &&
!config.fresh?.()
? await cache.lookup({
url,
// Before a refresh picks the token, so validate nothing yet.
Expand Down
Loading