Skip to content

feat(toolkit-core): Share the API transport with the CLI - #1719

Merged
BYK merged 1 commit into
mainfrom
feat/toolkit-core-api-transport
Oct 29, 2026
Merged

BYK merged 1 commit into
mainfrom
feat/toolkit-core-api-transport

Conversation

@sentry-junior

@sentry-junior sentry-junior Bot commented Oct 29, 2026

Copy link
Copy Markdown
Contributor

Moves the CLI's request loop out of packages/cli/src/lib/sentry-client.ts and into a new Workers-safe module, @sentry/toolkit-core/api-transport, then switches the CLI over to it. This covers tasks 5.1 and 5.2 of #1669 (unify-sentry-api-layer). It builds on the shared retry policy from #1710.

What's now shared

createApiTransport returns a fetch-shaped request() that owns:

  • Auth: it sets the Authorization header from an injected CredentialProvider. The provider supplies the current credential, an optional refresh before expiry, and the refresh after a 401. A 401 refreshes once and replays the request for any method.
  • Retries through api-retry. A 429 retries for any method. A 500/502/503/504, a timeout, or a network error retries only for GET/HEAD/OPTIONS, or when the request is marked idempotent. Waits follow Retry-After or X-Sentry-Rate-Limit-Reset, under the caller's caps.
  • Per-attempt timeouts: a fixed value or a function of the URL, so the CLI keeps ENDPOINT_TIMEOUT_OVERRIDES.
  • Body replay on every attempt, stream bodies included, plus an optional buffered-body mode (auto / buffer / stream). In buffer mode, a body that fails mid-read can still be retried.
  • Validated redirects: it follows redirects by hand, sends the credential only to origins the host trusts, and records where the response came from (getValidatedResponseSource).
  • Cache port (TransportCache): it looks up GETs, revalidates stale entries with their ETag, stores successful GETs, and invalidates entries after a successful write.

The host plugs in header rules (which may reject a request), its error classes for timeouts, rate limits, and fatal fetch errors (such as TLS failures), a logger, and a span hook. The module uses no Node APIs.

CLI wiring

sentry-client.ts now just configures the transport. It passes in its stored credentials and refresh logic, its host-scope and claim checks (prepareHeaders), the custom TLS fetch, the disk response cache, its tracing span, and its existing error classes. It keeps RETRY_POLICY, ENDPOINT_TIMEOUT_OVERRIDES, and the x-sentry-cli-retry marker. getResponseRequestOrigin and getResponseCredentialIdentity now read the transport's provenance. fetch-input.ts re-exports extractInputUrl from the shared module.

Behavior changes

The goal is the same CLI behavior. One intentional difference:

  • An unused response body (one dropped before a retry, a refresh, or a redirect hop) is now cancelled without awaiting the cancel. When a stream is teed, cancelling one branch only settles after the other branch is read. Interceptors such as MSW hand out teed streams, so the old awaited cancel could hang a retry indefinitely. A regression test covers this.

Stacking (#1669)

The OpenSpec tasks file is unchanged here.

Verification

  • toolkit-core: 21 files, 269 tests pass, including the new api-transport.test.ts. It covers the retry scenarios, 401 refresh, timeouts, fatal errors, header rules, redirects, the cache, and the teed-body regression.
  • CLI unit tests: 11,735 pass. 6 fail, and those same 6 fail on clean main in this sandbox (file-permission tests in completions, telemetry, upgrade, code-scanner, file-changes).
  • tsc --noEmit passes for toolkit-core and cli. oxlint, oxfmt --check, and ast-grep scan are clean on the changed paths.

via BYK.

--

View Junior Session in Sentry

Move the CLI's request loop from sentry-client.ts into
@sentry/toolkit-core/api-transport: the Authorization header, the 401
refresh (any method, once), per-attempt timeouts, retries through
api-retry, request-body replay, validated redirect following with
origin provenance, and GET cache lookup, revalidation, storage, and
post-write invalidation. The host injects a CredentialProvider, header
rules, a cache, its error classes, a logger, and a span hook, so the
module stays free of Node APIs.

The CLI now configures the transport with its stored credentials,
host-scope checks, disk cache, telemetry span, and error classes.

Implements #1669 tasks 5.1 and 5.2.

Co-Authored-By: BYK <burak.kaya@sentry.io>
@vercel

vercel Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
sentry-local Ready Ready Preview Oct 29, 2026 12:00pm UTC

Request Review

@BYK
BYK enabled auto-merge (squash) October 29, 2026 12:05
@BYK
BYK merged commit cbe5e87 into main Oct 29, 2026
28 checks passed
@BYK
BYK deleted the feat/toolkit-core-api-transport branch October 29, 2026 12:13
sentry-junior Bot added a commit that referenced this pull request Oct 29, 2026
5.3 is in review in #1720, and 6.1 is stacked on it.

Co-Authored-By: BYK <burak.kaya@sentry.io>
Comment on lines +478 to +494
if (response.status === 401 && attempt < retry.maxRetries) {
const next =
refreshed || !credentials.refresh
? undefined
: await credentials.refresh(credential);
if (!next) {
return { action: "done", response };
}
refreshed = true;
credential = next;
headers.set("Authorization", authorization(next));
if (config.refreshedRequestHeader) {
headers.set(config.refreshedRequestHeader, "1");
}
cancelBody(response);
return { action: "retry", delayMs: 0 };
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

401 credential refresh consumes api-retry attempt budget

A 401 refresh is gated on attempt < retry.maxRetries and reuses the same attempt counter as transient retries, so an expired-token refresh leaves one fewer 5xx/timeout retry and starts backoff at the next step. Gate refresh only on !refreshed and keep that replay out of the retry budget.

Evidence
  • api-retry.ts states a 401 credential refresh is not a retry; the transport may replay once because the endpoint never ran.
  • onResponse only refreshes when attempt < retry.maxRetries, then returns { action: "retry", delayMs: 0 }, which advances the shared for (let attempt = 0; ; attempt++) loop.
  • nextRetryDelayMs still uses that incremented attempt for both the remaining retry count (attempt >= maxRetries) and backoff (baseDelayMs * 2 ** attempt).
  • With the CLI/MCP default maxRetries: 2, 401 → 503 → 503 fails after refresh, while 503 → 503 → 200 would succeed without the 401.

Identified by Warden · code-review · TTN-XGY

BYK added a commit that referenced this pull request Oct 29, 2026
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>
BYK added a commit that referenced this pull request Oct 29, 2026
This PR sends every MCP request to the Sentry API through the shared
transport, and moves retries out of `SentryApiService` into it. It is
task **6.1** of #1669 (`unify-sentry-api-layer`) and builds on the
shared transport (#1719) and the fresh-read hook (#1720).

### What changed

- **One transport:** `SentryApiService` sends every request through
`createApiTransport` from `@sentry/toolkit-core/api-transport`, using
the MCP's existing `REQUEST_RETRY_POLICY`. That includes `@sentry/api`
operations, which already go through `request()`.
- **One retry layer:** `SentryApiService` no longer has its own retry
loop or network-failure bookkeeping. The shared transport retries a 429
for any method, and a 500/502/503/504 or a network failure for GET and
HEAD. `SentryApiService` still parses the final error response, so
`ApiRateLimitError.retryAfterMs` is still read from `Retry-After` or
`X-Sentry-Rate-Limit-Reset`.
- **Transport options:** `credentials` and `timeoutMs` on
`createApiTransport` are now optional. Without a `CredentialProvider`,
the transport keeps the `Authorization` header the request already has,
never refreshes, and skips the cache, because cache entries are tied to
a credential. The MCP uses it this way, and still sets no timeout, as
before.
- **Abort reason:** an abort while a request is in flight now rejects
with the caller's `signal.reason`, instead of being treated as a
retryable network failure.

An earlier revision also added a host-supplied `fetch` option to
`McpServerOptions` and `ServerContext`. That is gone: per the revised
#1669 plan, the shared API layer moves into toolkit-core and then
`@sentry/api`, and the CLI passing its fetch to `sentry mcp` is
deferred.

### Intentional behavior changes

- **`allowStatuses`:** a status a caller lists there is now returned
only after the transport has finished retrying it. Before, it came back
on the first attempt. Today's callers only list 404 and 400, which are
never retried, so they see no difference.
- **Abort during a retry wait:** if the caller's signal aborts while the
transport is waiting to retry, the abort error is rethrown as-is. It is
no longer wrapped as a connection failure.

### Relation to #1669

- Done: 5.0 (#1673, #1710), 5.1 and 5.2 (#1719), 5.3 (#1720).
- This PR: 6.1.
- Next: consolidate the MCP's `SentryApiService` and the CLI's API
modules into one browser-safe API client in toolkit-core, stacked on
this PR.

### Verification

- New `mcp-core` tests:
- A GET that is rate-limited on every attempt reaches the API at most
`maxRetries + 1` times.
- An in-flight abort rejects with the caller's reason, after one
attempt.
- New `toolkit-core` tests:
  - Without credentials, the request's own `Authorization` is kept.
  - Without credentials, the cache is never consulted.
  - An in-flight abort keeps the caller's reason.
- Suites:

  | Package | Result |
  | --- | --- |
  | `toolkit-core` | 273 pass |
  | `mcp-core` | 1,899 pass, 6 skipped |
  | `mcp-server` | 87 pass |
  | `mcp-cloudflare` | 443 pass |

- `tsc --noEmit` passes for `toolkit-core`, `mcp-core`, `mcp-server`,
`mcp-cloudflare`, and `cli`. `oxlint` and `oxfmt --check` are clean on
the changed files.
- Test changes:
- Hand-built response mocks in `client.test.ts` gained a `status`,
because the transport reads it to decide on a retry.
- Header assertions in `client.test.ts` and `mcp-handler.test.ts` now
read through `Headers`, because the transport passes a `Headers` object.

<!-- 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>

This branch was successfully deployed

1 active deployment
Preview — f430f9d4 Deployed Oct 29, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk: medium PR risk score: medium

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant