Skip to content

Commit 6fe02ce

Browse files
feat(toolkit-core): Let a request scope bypass cached reads (#1720)
This adds a request-scoped fresh-read bypass to the shared API transport in `@sentry/toolkit-core/api-transport`. It is task **5.3** of #1669 (`unify-sentry-api-layer`) and builds on the shared transport from #1719. ### What's shared `createApiTransport` accepts a new optional `fresh?(): boolean` hook. While the hook returns true: - GETs skip the cache lookup and go to the network. - The response is still stored, so it replaces the cached entry and later reads see the new data. A host backs the hook with request-scoped state, for example `AsyncLocalStorage` around a single MCP tool call that asked for `fresh: true`. That keeps the bypass to one call rather than the whole process. This is the transport side of the MCP's `fresh` input planned in 6.3. ### Behavior changes None for current callers. The hook is optional, and no host sets it yet. The CLI's `--fresh` flag keeps its existing process-wide bypass in `cli/src/lib/response-cache.ts`, with reads skipped and writes kept as before. This PR leaves that unchanged. ### Relation to #1669 - Done: 5.0 shared retry policy (#1673, #1710), and 5.1 + 5.2 shared transport with the CLI switch (#1719). - This PR: 5.3. - Next, stacked on this branch and opened once it merges: 6.1. That adds `fetch` to `McpServerOptions` and routes all `SentryApiService` and `@sentry/api` requests through it. `SentryApiService` drops its own retry loop, so retries happen in the shared transport only. ### Verification - New transport test `skips the cache in a fresh scope and replaces the entry`. It stores v1, then reads in a fresh scope and gets v2 from the network. Once the fresh scope ends, the next read is a cache hit for v2. - `toolkit-core`: 21 files and 270 tests pass. - CLI request, cache and API tests (`test/lib/sentry-client*`, `test/lib/response-cache*`, `test/lib/api`): 49 files and 1,049 tests pass. - `tsc --noEmit` passes for `toolkit-core` and `cli`. - `oxlint`, `oxfmt --check` and `ast-grep scan` are clean on the changed files. <!-- junior-request-attribution:start --> via **BYK**. <!-- junior-request-attribution:end --> <!-- junior-session-footer:start --> <!-- junior-conversation-id:agent%3Aa6eddc0161cff61914454da9f74fcdf1 --> -- [View Junior Session in Sentry](https://sentry.sentry.io/explore/conversations/agent%3Aa6eddc0161cff61914454da9f74fcdf1/?project=4510944073809921) <!-- junior-session-footer:end --> Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> Co-authored-by: BYK <burak.kaya@sentry.io>
1 parent cbe5e87 commit 6fe02ce

3 files changed

Lines changed: 44 additions & 2 deletions

File tree

‎packages/toolkit-core/README.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ With `validateRedirects`, it follows redirects by hand, sends the credential
2626
only to origins the host trusts, and records the final origin for
2727
`getValidatedResponseSource`. With a cache, a GET is looked up first (a stale
2828
entry is revalidated with its `ETag`), a successful GET is stored, and a
29-
successful write invalidates what it made stale.
29+
successful write invalidates what it made stale. While the host's `fresh`
30+
hook returns true, such as during a tool call that asked for fresh data, GETs
31+
skip the lookup and their responses replace the cached entry.
3032

3133
The host passes in a `CredentialProvider` (the current credential, an optional
3234
refresh ahead of expiry, and the 401 refresh), header rules that may reject a

‎packages/toolkit-core/src/api-transport.test.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -536,6 +536,37 @@ describe("cache", () => {
536536
expect(sent).toHaveLength(1);
537537
});
538538

539+
it("skips the cache in a fresh scope and replaces the entry", async () => {
540+
const cache = memoryCache();
541+
const { fetch, sent } = scriptedFetch([
542+
Response.json({ v: 1 }),
543+
Response.json({ v: 2 }),
544+
]);
545+
let fresh = false;
546+
const api = transport(fetch, { cache, fresh: () => fresh });
547+
const stored = async (v: number) =>
548+
await vi.waitFor(async () => {
549+
const lookup = await cache.lookup({
550+
url: ISSUES_URL,
551+
requestHeaders: { authorization: "Bearer token-a" },
552+
identity: "id-a",
553+
});
554+
expect(
555+
lookup?.kind === "hit" ? await lookup.response.json() : undefined,
556+
).toEqual({ v });
557+
});
558+
559+
await api.request(ISSUES_URL);
560+
await stored(1);
561+
fresh = true;
562+
expect(await (await api.request(ISSUES_URL)).json()).toEqual({ v: 2 });
563+
await stored(2);
564+
fresh = false;
565+
566+
expect(await (await api.request(ISSUES_URL)).json()).toEqual({ v: 2 });
567+
expect(sent).toHaveLength(2);
568+
});
569+
539570
it("revalidates a stale entry with its ETag", async () => {
540571
const response = await transport(
541572
scriptedFetch([new Response(null, { status: 304 })]).fetch,

‎packages/toolkit-core/src/api-transport.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,12 @@ export type ApiTransportOptions<C extends TransportCredential> = {
106106
): Promise<Error | undefined>;
107107
};
108108
cache?: TransportCache;
109+
/**
110+
* Whether the current scope skips cache reads, such as a tool call with
111+
* `fresh: true`. Hosts back it with request-scoped state like
112+
* `AsyncLocalStorage`. The response is still stored, replacing the entry.
113+
*/
114+
fresh?(): boolean;
109115
logger?: TransportLogger;
110116
/** Wrap each request, including cache lookups, in a host span. */
111117
span?(
@@ -663,7 +669,10 @@ export function createApiTransport<C extends TransportCredential>(
663669
// A cached response has no validated origin, so discovery always
664670
// goes to the network.
665671
const lookup =
666-
cache && method === "GET" && !options.validateRedirects
672+
cache &&
673+
method === "GET" &&
674+
!options.validateRedirects &&
675+
!config.fresh?.()
667676
? await cache.lookup({
668677
url,
669678
// Before a refresh picks the token, so validate nothing yet.

0 commit comments

Comments
 (0)