Skip to content

docs(openspec): Propose a shared Sentry API layer for the CLI and MCP - #1669

Open
sentry-junior[bot] wants to merge 23 commits into
mainfrom
feat/unify-sentry-api-layer-proposal
Open

sentry-junior[bot] wants to merge 23 commits into
mainfrom
feat/unify-sentry-api-layer-proposal

Conversation

@sentry-junior

@sentry-junior sentry-junior Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Adds the OpenSpec change unify-sentry-api-layer. It covers the proposal, the capability specs, the design, and the tasks. It is docs only; implementation lands in follow-up PRs.

What it proposes

  • Drop the CLI's 24-hour "immutable" cache tier, which also covered issue events (including /issues/<id>/events/latest/), project events, and full traces. Those change after they are first read, so they drop to 60 seconds; organization event and trace-item details fall to the default 5 minutes. Landed in fix(cli): Stop caching events and traces for 24 hours #1681 (task group 2).
  • Move the CLI's pagination, response-cache policy, and request transport into toolkit-core, on top of @sentry/api. The CLI and MCP then share one implementation, with credentials, storage, and telemetry supplied by each host. Pagination landed in ref(toolkit-core): Share limit-bounded pagination with the MCP #1683 (task group 3) and the cache policy in ref(toolkit-core): Share the response cache policy with the MCP #1688 (task 4.1), and the body codec in feat(toolkit-core): Add a body codec for cached responses #1695 (task 4.2).
  • The MCP's SentryApiService sends every request through the shared transport, with no retry loop of its own (ref(mcp): Send Sentry API requests through the shared transport #1722).
  • Two steps to one API client:
    1. Consolidate every SentryApiService-like layer, the MCP's SentryApiService and the CLI's API modules, into one shared client in toolkit-core, in one PR. It covers endpoint wrappers by domain on @sentry/api operations, errors (ApiError, rate limits, retryAfterMs), Zod validation (@sentry/api/zod where it covers the endpoint), pagination, cache policy, retries, and the transport (task group 8).
    2. Copy it into getsentry/sentry-api-schema, publish a new @sentry/api, switch toolkit to it, and delete the toolkit-core copies (task group 9).
  • Portable: the shared client uses only @sentry/api, the modules that move with it, and web-standard APIs, so it runs in Node, Workers, and browsers, and the Sentry frontend can use it. Credentials, storage, the fresh-read scope, and telemetry are injected by each host. A lint rule and a browser-target typecheck enforce it.
  • Deferred to a later phase (task group 11): sentry mcp passing the CLI's fetch, widening CLI OAuth scopes, deprecating the standalone MCP login, and using the CLI as an SDK from the MCP.
  • The hosted MCP gets a tiered cache:
    • an in-memory LRU, limited by bytes, for every tier
    • R2 for an explicit allowlist of organization event details and project trace-item details, only when the server marks them private, max-age, for at most 5 minutes. Enabling it in production needs a security sign-off on the stale window after access revocation or deletion.
    • zstd level 3 compression on both
  • MCP read tools gain a fresh input, like the CLI's --fresh. The request-scoped flag lives in the MCP host, not in shared code.
  • Both products use one @sentry/api version from the catalog.

Why not createSentrySDK()

  • The CLI already depends on @sentry/mcp-server, so the MCP calling the SDK would create a dependency cycle.
  • The SDK needs SQLite and a filesystem, which Workers lack.
  • SDK errors flatten HTTP status into exit codes, but the MCP needs the status to decide what gets logged to Sentry.

Evidence behind the design

Checks: openspec validate unify-sentry-api-layer --strict and oxfmt pass.

via BYK.

--

View Junior Session in Sentry

Share pagination, response caching, and request transport between the CLI
and MCP through toolkit-core on top of @sentry/api, let sentry mcp supply the
CLI's authenticated transport, and add a tiered (memory + R2), zstd-compressed
cache for the hosted MCP.

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

vercel Bot commented Oct 26, 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 8:28pm UTC

Request Review

@github-actions github-actions Bot added the risk: low PR risk score: low label Oct 26, 2026
Benchmark zstd, brotli, gzip and trained zstd dictionaries on real API
responses. Keep plain zstd for now, cap entries on compressed size, and
move dictionary support to a follow-up gated on safe training data and
Workers support (workerd ignores the zstd dictionary option).

Co-Authored-By: BYK <burak.kaya@sentry.io>
BYK added a commit that referenced this pull request Oct 26, 2026
…ry (#1671)

`search_issues` was sending AND/OR to the issues endpoint, which rejects
them with `Boolean statements containing "OR" or "AND" are not supported
in this search`. That's the top `search_issues` failure in
`sentry/mcp-server`: about 93K a week across many users, mostly `is:X OR
is:Y`.

Two things in MCP let them through:

- The repair prompt (`search-issues/config.ts`) listed `AND, OR, NOT` as
valid syntax and told the agent to keep explicit tokens.
- With no agent provider configured, queries went to the API unchecked.

**What changes**

- **The CLI sanitizer moves to `@sentry/toolkit-core/search-query`.**
`rewriteSearchQuery` returns `{ query, notes }` and throws
`SearchQueryError`. Each product decides how to report: the CLI wrapper
(`cli/src/lib/search-query.ts`) keeps its stderr warning and
`ValidationError`, and MCP throws `UserInputError`. CLI behavior is
unchanged except one error line, which now reads `key:[val1,val2]`
instead of `--query "key:[val1,val2]"`.
- **`search_issues` runs it after the optional agent repair**, right
before `listIssues`, so it covers the no-provider path too. When it
rewrites a query, the result says so and shows the query it ran. Queries
it can't rewrite fail with a `UserInputError` that lists alternatives,
before any API call.
- **New `issueSearch` option** (MCP only for now):
- `is:A OR is:B` becomes `status:[A,B]` or `substatus:[A,B]`. `is:`
rejects in-lists, but the status and substatus filters it maps to accept
them.
- Substatus lists must share one parent status, because the endpoint
adds a status filter for the first substatus only
(`expand_substatus_query_values`). So `is:ongoing OR
is:archived_forever` is still rejected.
- Parentheses that reduce to plain terms are unwrapped: `is:unresolved
(is:regressed OR is:escalating)` becomes `is:unresolved
substatus:[regressed,escalating]`.
- **The agent prompt and tool description now say** there's no
AND/OR/NOT or parentheses, and give the in-list, `status:`, and
`substatus:` forms instead.

**Checked against the live API** (sentry org, `junior` project, 14d,
`X-Hits`):

- `status:[unresolved,resolved]` returned 165, which is 159
(`is:unresolved`) + 6 (`is:resolved`).
- `substatus:[new,ongoing]` returned 159, which is 53 (`is:new`) + 106
(`is:ongoing`).
- `substatus:[ongoing,archived_forever]` returned only unresolved
issues, and `[archived_forever,ongoing]` returned none. That's the
first-substatus behavior the parent-status check guards against.
- `(is:unresolved)` returned `[]` and `NOT is:resolved` returned `[]`
(NOT is searched as free text), both with a 200. That's why the prompt
now says `!` instead of NOT, and why parentheses get unwrapped.
- `is:[unresolved,resolved]` returned a 400: `"in" syntax invalid for
"is" search`.

**Reviewing the move**

Git shows the core file as new, because the CLI path still holds the
wrapper. To see only what changed in the moved code:

```
git diff main:packages/cli/src/lib/search-query.ts HEAD:packages/toolkit-core/src/search-query.ts
```

**Not in this PR**

- The CLI's `issue list` could opt into `issueSearch`. I left it off to
keep CLI behavior unchanged.
- MCP's other query tools don't use the shared sanitizer yet.
- The 429 retry in `get_sentry_resource` is task 4.1 of #1669.

**Checks**

- `pnpm run tsc` and `pnpm run lint` pass.
- Tests pass for `toolkit-core` (356), `mcp-core` (1833), and the CLI
search-query, issue, log, span, and replay tests (532).
- The full CLI `test/lib` + `test/commands` run has 6 failures in
filesystem-permission tests (completions, upgrade lock, code scanner,
file-changes). The completions and upgrade-lock failures also happen on
`main` in this sandbox, so they look environment-specific.

<!-- junior-request-attribution:start -->
via @BYK.
<!-- junior-request-attribution:end -->

<!-- junior-session-footer:start -->
<!-- junior-conversation-id:slack%3AC08J1NSPU6S%3A1792772794.175999 -->

--

[View Junior
Session](https://junior-prod.sentry.dev/conversations/slack%3AC08J1NSPU6S%3A1792772794.175999)
[[Sentry]](https://sentry.sentry.io/explore/conversations/slack%3AC08J1NSPU6S%3A1792772794.175999/?project=4510944073809921)

<!-- junior-session-footer:end -->

---------

Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com>
Co-authored-by: Burak Yigit Kaya <126780+BYK@users.noreply.github.com>
Co-authored-by: Andrew Liu <andrew.liu@sentry.io>
The getsentry/sentry#128818 spike found that no event or trace endpoint is
safe to cache as immutable. Issue and project event details embed
next/previous IDs and user reports, `latest` resolves to different events
over time, and full traces gain spans. The CLI's 24-hour tier still matches
all of them, including `/issues/<id>/events/latest/`.

Replace the immutable tier with a 5-minute detail tier limited to
organization event details and project trace-item details, and move issue
events, project events, and full traces to 60 seconds. Add the CLI tier
correction as its own task group ahead of the toolkit-core move.

R2 now persists only detail-tier entries that the server marks
`private, max-age`, for at most 5 minutes, and needs a security sign-off
before production. Use zstd level 3 for every tier instead of level 9 for R2.

Co-Authored-By: BYK <burak.kaya@sentry.io>
BYK added a commit that referenced this pull request Oct 26, 2026
…1672)

The CLI and MCP core pinned different `@sentry/api` versions, so the two
products could disagree on generated types and request helpers. This
adds `@sentry/api: 0.400.0` to the pnpm catalog and points
`packages/cli` and `packages/mcp-core` at `catalog:`. The lockfile now
resolves a single `@sentry/api` version.

MCP core needed no code changes for the bump.

This is task 1 of the `unify-sentry-api-layer` OpenSpec change in #1669.

Checks run locally:

- `pnpm install --frozen-lockfile`
- `pnpm --filter @sentry/mcp-core run tsc`
- `pnpm --filter @sentry/mcp-core run test` (131 files, 1,831 passed, 6
skipped)
- `pnpm --filter @sentry/mcp-core run build`
- `tsc` for `@sentry/mcp-server` and `@sentry/mcp-cloudflare`
- Formatter check on changed files

The full root quality gate and the CLI suite were not run locally; CI
covers them.

<!-- junior-request-attribution:start -->
via **BYK**.
<!-- junior-request-attribution:end -->

<!-- junior-session-footer:start -->
<!-- junior-conversation-id:local%3Aweb%3A0ab4fc2bd5a2e23f2c25efcf -->

--

[View Junior Session in
Sentry](https://sentry.sentry.io/explore/conversations/local%3Aweb%3A0ab4fc2bd5a2e23f2c25efcf/?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>
…ders

Neither product handles 429s well. The CLI retries every method, POSTs
included, and ignores rate-limit headers. The MCP retries only GET 502/503/504,
so the ~32.6K rate-limited GETs it sees a week fail immediately.

Add task 5.0 for a pure `toolkit-core/src/api-retry.ts` policy: retry only
GET/HEAD/OPTIONS unless a request opts in as idempotent, wait for
Retry-After or X-Sentry-Rate-Limit-Reset with a clock-skew guard and caps,
fall back to jittered backoff, and sleep abortably. The MCP adopts it first.
Task 5.1 now moves idempotent-only retry through it and calls the CLI's
all-methods retry a bug fixed separately; 6.1 drops the MCP's own retry once
it is on the transport so retries don't stack. Add a sentry-api-transport
spec delta for these rules.

Co-Authored-By: Burak Yigit Kaya <126780+BYK@users.noreply.github.com>
@sentry-junior

sentry-junior Bot commented Oct 26, 2026

Copy link
Copy Markdown
Contributor Author

Plan update (a81439e): retries and 429s.

  • New task 5.0: a pure, Workers-safe toolkit-core/src/api-retry.ts. It retries only GET/HEAD/OPTIONS unless a request opts in with idempotent. It waits for Retry-After (seconds or HTTP-date) or, on a 429, X-Sentry-Rate-Limit-Reset, measured against the response Date when the local clock is off. Each product sets per-wait and total-wait caps. Concurrency 429s and responses without headers fall back to jittered backoff, and sleeps take an abort signal. The MCP adopts it first. The CLI's interim fix moves onto it in a follow-up.
  • 5.1: the transport now does idempotent-only retry through api-retry, and 401 refresh stays allowed for any method. The CLI's old all-methods retry is called out as a bug, fixed separately.
  • 6.1: SentryApiService drops its own retry when it moves onto the transport, so retries don't stack.
  • New spec delta specs/sentry-api-transport/spec.md. Its scenarios: a 429 with a reset header waits within the cap and retries; a POST without the opt-in is never retried; a 401 refresh replays any method; a wait over the cap returns a rate-limit error that says when to retry; and retries happen in one layer only.
  • design.md gains a short "Retries are for idempotent requests and honor rate-limit headers" decision, and proposal.md lists the new capability.

openspec validate unify-sentry-api-layer --strict passes.

…uses

Sentry's RatelimitMiddleware rejects a 429 in process_request, before the view
runs, so replaying one cannot repeat a write. The sentry-api-transport spec
now retries 429s for any method, and retries 5xx, timeouts, and network errors
only for GET/HEAD/OPTIONS or an explicit idempotent opt-in.

The retried statuses are 429, 500, 502, 503, and 504. Other 5xx codes are
deterministic. The clock-skew wording now matches the Date-bounded delay from
#1673. The long requirements are split so `openspec validate --strict` passes.
@sentry-junior

sentry-junior Bot commented Oct 27, 2026

Copy link
Copy Markdown
Contributor Author

Correction to my earlier plan comment (b5523f5), following review on #1673:

  • 429s are retried for any method. Sentry's RatelimitMiddleware.process_request rejects the request before the view runs, so a replay can't repeat a write.
  • 5xx responses, timeouts and network errors are retried only for GET/HEAD/OPTIONS or with the idempotent opt-in.
  • Retried statuses: 429, 500, 502, 503 and 504. Other 5xx codes such as 501 and 505 are deterministic, so they aren't retried.
  • Clock skew: the wording now matches fix(mcp): Retry rate-limited GETs using Sentry rate-limit reset headers #1673. The delay is kept within the range the response Date allows.

The sentry-api-transport requirements are split up so that openspec validate --strict passes.

BYK added a commit that referenced this pull request Oct 27, 2026
…tions on missing org (#1678)

## Problem
Telemetry from `sentry/mcp-server` (spans over 7 days, extrapolated from
0.3 sampling, so user counts are lower bounds):
- **Missing `organizationSlug`:** about 23.8K failures from 861+ users.
One user sent ~17.7K of them, all `search_issues` with `period:"1h"`.
Without them it's ~6.1K failures across ~860 users. All of these came in
on the unscoped `/mcp` URL. They fail in the MCP SDK's input validation
before any handler runs, so the zod error message is the only place to
add guidance without changing the schema.
- **`period: "1h"` on `search_issues`:** about 25.6K failures from ~50
users, mostly that same user.
- **Minute periods:** values like `30m`, `15m` and `10m` on
`search_errors`, `search_logs` and `search_traces` were never accepted.
That's about 1.5K failures from ~300 users. Sentry reads `m` as minutes
(read-only GET check: `30m` returned 28 errors, against 1,269 for
`24h`), and the issues endpoint accepts `statsPeriod=1h`.

## Changes
- **`SEARCH_ISSUES_PERIOD_VALUES`** now includes `1h`.
- **`ParamPeriod`** now accepts minutes (`/^\d+[mhdw]$/`), and the
rejection message says "(minutes)".
- **A missing or null `organizationSlug`** now returns: `Required. Call
find_organizations() to list your organizations, then retry with
organizationSlug set to one of their slugs.` This works on SDK v1 and v2
and doesn't change the JSON schema.
- **Tool definitions** are regenerated. Token overhead goes up by 13
(10,066 → 10,079).

## Held back
- **Defaulting to the user's only org.** This would mean making
`organizationSlug` optional on ~50 tools, and fetching the org list plus
a region lookup on every call that has no org. That's the org endpoint,
cache and region-routing code that #1669 reworks, so it's left for that
work.
- **Listing the user's orgs in the error, with a ready-to-copy retry.**
Same blocker: the SDK rejects the call before our handler sees it.

## Checks
- `pnpm run tsc` and `pnpm run lint` pass.
- mcp-core: 1847 passed, 6 skipped.

## Risk
A model that means months by `3m` would get 3 minutes of data. Telemetry
shows callers mean minutes; there are only a handful of `6m`/`12m`. None
of this fixes the stuck script.

<!-- junior-request-attribution:start -->
via **BYK**.
<!-- junior-request-attribution:end -->

<!-- junior-session-footer:start -->
<!-- junior-conversation-id:slack%3AC08J1NSPU6S%3A1792772794.175999 -->

--

[View Junior
Session](https://junior-prod.sentry.dev/conversations/slack%3AC08J1NSPU6S%3A1792772794.175999)
[[Sentry]](https://sentry.sentry.io/explore/conversations/slack%3AC08J1NSPU6S%3A1792772794.175999/?project=4510944073809921)

<!-- junior-session-footer:end -->

---------

Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com>
Co-authored-by: Burak Yigit Kaya <126780+BYK@users.noreply.github.com>
BYK added a commit that referenced this pull request Oct 27, 2026
…#1679)

## Problem
These are the most common field and sort errors in `sentry/mcp-server`
telemetry (7 days, extrapolated from 0.3 sampling, so user counts are
lower bounds). Each fix was checked against the live API.
- **`search_logs` with `min/max(timestamp)`:** ~2.6K failures from ~470
users. Log timestamps are strings, so the fix is
`min/max(timestamp_precise)` (nanoseconds). Metrics behaves the same way
(43 failures).
- **`search_errors` with `first_seen()`:** ~2.3K failures from ~590
users. That function doesn't exist; use `min(timestamp)`, plus
`max(timestamp)` or `last_seen()`. About 700 of these came from fields
the embedded agent picked.
- **`search_errors` sorted by `issue`:** ~1K failures from ~270 users.
Sentry rejects this sort even when `issue` is selected; use `-count()`
or `issue.id`.

## Changes
- **New `tools/support/search-events/field-hints.ts`.** It adds a hint
only when the request actually sent the rejected expression and Sentry's
error message matches it.
- Hints are fixed text and never include caller input, and Sentry's own
message is kept.
- Anything that doesn't match is re-thrown unchanged as
`ApiValidationError`.
- **Hints are added in two places:** the validation failure, and the
final Events API 400. The second is needed because the validate endpoint
accepts the `first_seen()` and `issue`-sort cases.
- **`min(timestamp)`** is added to the embedded agent's errors field
list.

## Not covered
- `timestamp.to_day` / `to_hour` on spans and logs (~600 failures):
there's no clear one-line replacement.
- Metrics `count()` arity (~350), `p10`/`p25` (~150), and `count(span)`
(0).

## Checks
- tsc, lint (oxfmt) and all mcp-core tests pass (1837 passed, 6
skipped).
- Tool definitions are unchanged (10,066 tokens).

## Relation to #1669
Task 7.3 moves the explore, traces and logs endpoints onto
`@sentry/api`. These hints only depend on the error being an
`ApiValidationError` and on its message, so they should carry over as
long as 400s still map to that class.

<!-- junior-request-attribution:start -->
via **BYK**.
<!-- junior-request-attribution:end -->

<!-- junior-session-footer:start -->
<!-- junior-conversation-id:slack%3AC08J1NSPU6S%3A1792772794.175999 -->

--

[View Junior
Session](https://junior-prod.sentry.dev/conversations/slack%3AC08J1NSPU6S%3A1792772794.175999)
[[Sentry]](https://sentry.sentry.io/explore/conversations/slack%3AC08J1NSPU6S%3A1792772794.175999/?project=4510944073809921)

<!-- junior-session-footer:end -->

Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com>
Co-authored-by: Burak Yigit Kaya <126780+BYK@users.noreply.github.com>
BYK added a commit that referenced this pull request Oct 27, 2026
…rs (#1673)

The hosted MCP gets rate limited by the Sentry API a lot. In the last 7
days `sentry/mcp-server` logged about 32.6K `http.client` 429s, nearly
all on GETs, usually "Limit is 40 requests in 1 seconds", plus about 72
concurrency-limit hits. `SentryApiService.request()` only retried
502/503/504, so every 429 failed the tool call.
`ApiRateLimitError.retryAfter` was read from body fields Sentry never
sends, so nothing could say when to try again.

Sentry's 429 body is just `{"detail": ...}`. The timing comes in headers
set by `src/sentry/middleware/ratelimit.py`: `X-Sentry-Rate-Limit-Reset`
is the Unix second when the next window starts, and `Remaining` is `0`
only when the fixed window is used up. Concurrency 429s carry a reset
that doesn't apply to them.

This adds `@sentry/toolkit-core/api-retry`, a pure retry policy with no
Node APIs, so it is safe on Workers. It is task 5.0 in #1669. The MCP
adopts it here:

- **What gets retried:** GET and HEAD requests that get a 429 or a
502/503/504. Writes are never retried. The policy's `idempotent` opt-in
exists for the CLI's content-addressed uploads, but the MCP doesn't use
it.
- **How long a 429 waits:** until `Retry-After` (seconds or HTTP-date)
if a proxy sent one, otherwise until `X-Sentry-Rate-Limit-Reset`. The
reset is measured against the response `Date` when the local clock is
off by more than 2s, plus a small jitter. Concurrency 429s and gateway
errors use equal-jitter exponential backoff starting at 250ms.
- **Caps:** at most 2 retries, 2s per wait and 3s in total. Sentry's
windows are mostly 1s, so the reset is usually under a second away and
both retries fit in about 2s. A reset further out than 2s means a longer
window. Then the call fails at once instead of holding the Worker
request open. The error is an `ApiRateLimitError` with a real
`retryAfterMs`, and its message ends with "Retry in N seconds." When
there is no header, it says to wait a few seconds and make fewer
parallel requests.
- **Abort:** aborting the request's `signal` ends the wait immediately.

`ApiRateLimitError.retryAfter` (seconds, from the body) is replaced by
`retryAfterMs`, taken from the headers. Nothing read the old field.

The CLI currently has its own interim retry fix in a separate CLI-only
PR. It moves onto this shared module in a follow-up after that PR lands.
Once the MCP is on the shared transport (#1669 task 6.1),
`SentryApiService` drops this loop so retries don't stack.

<!-- junior-request-attribution:start -->
via @BYK.
<!-- junior-request-attribution:end -->

<!-- junior-session-footer:start -->
<!-- junior-conversation-id:agent%3A0bac2c6dfda59b43b43e21a1118f2684 -->

--

[View Junior
Session](https://junior-prod.sentry.dev/conversations/agent%3A0bac2c6dfda59b43b43e21a1118f2684)
[[Sentry]](https://sentry.sentry.io/explore/conversations/agent%3A0bac2c6dfda59b43b43e21a1118f2684/?project=4510944073809921)

<!-- junior-session-footer:end -->

---------

Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com>
Co-authored-by: Burak Yigit Kaya <126780+BYK@users.noreply.github.com>
BYK added a commit that referenced this pull request Oct 27, 2026
…reset on 429 (#1677)

## Problem
`packages/cli/src/lib/sentry-client.ts` retried **every** HTTP method,
including POST/PUT/DELETE, on 408/429/500/502/503/504 and on network
errors and timeouts. That can replay writes. A 429 also only got the
generic 1s/2s backoff: the client never read Sentry's
`X-Sentry-Rate-Limit-Reset` header, and Sentry doesn't send
`Retry-After` on API rate limits.

## Behavior changes
- **5xx, timeouts and network errors** now retry only for GET, HEAD and
OPTIONS, or when a request sets the new `idempotent: true` opt-in on
`getSdkConfig` / `ApiRequestOptions`.
- **429 retries for any method.** Sentry's rate-limit middleware rejects
the request in `process_view`, before the endpoint runs, so nothing was
applied.
- **Retried statuses stay 408/429/500/502/503/504.** Deterministic 5xx
like 501 and 505 are not retried.
- **PUT and DELETE are deliberately not retried.** Sentry PUTs have side
effects (merge, activity), and a replayed DELETE returns 404.
- **The 401-refresh retry still covers every method**, and no longer
waits 1s first.
- **A timed-out write** now gets a "may still have been applied" hint.
- **The 429 wait** is the longer of `X-Sentry-Rate-Limit-Reset` (read
only when Remaining is 0) and `Retry-After` (seconds or HTTP date), both
measured against the response `Date` to guard against clock skew.
Concurrency-limit 429s, or 429s with no hint, use jittered backoff: the
upper half of min(1s·2^n, 10s).
- **Caps are unchanged:** `MAX_RETRIES` is 2 and each wait is at most
10s.
- **New `RateLimitError`** (extends `ApiError`, status 429). It's thrown
when the wait would exceed the cap or retries run out. The message is
agent-friendly: server detail, then "Retry in N seconds; do not repeat
the request before then. Make fewer requests in parallel."
- **The policy is pure**, with no CLI imports, in
`packages/cli/src/lib/retry-policy.ts`. That way the CLI can switch to
the shared `@sentry/toolkit-core/api-retry` from #1673 without a
rewrite.

## POST callers given the `idempotent` opt-in
Each was checked against its getsentry/sentry handler:
- **Chunk uploads** (`chunk-upload.ts`): blobs are stored by SHA-1
(`FileBlob.from_files`).
- **Artifact-bundle assemble and `pollAssembly`** (`sourcemaps.ts`).
- **DIF assemble** in `debug-files.ts`, `proguard.ts` and
`dart-symbols.ts`: the server checks the cached assemble status by
checksum.

**Not opted in:** preprod assemble (each call creates a new
`PreprodArtifact`), Seer `/autofix/`, snapshot create and archive
trigger, and SDK and `sentry api` writes.

## Tests
- The POST-retry body-cloning tests now go through the 429 and opt-in
paths.
- New cases:
  - a rate-limited POST is resent with the same body
  - a 501 is not retried
  - a 503 on a non-idempotent method is not retried
- the reset-header and `Retry-After` wait, clock skew, and the cap
leading to `RateLimitError`
  - the exact rate-limit message text
- `api-client.coverage` used 429 as a sample error and now uses 400.

## Checks
- `tsc --noEmit` and `pnpm run lint` pass.
- The sentry-client, retry-policy, api-client and api-client.coverage
tests pass: 204/204.
- Full CLI suite on `bf3b710`: 11,482 passed and 6 failed. The same 6
fail on `main` (`37c7193`) in this sandbox. They're chmod-based
permission tests (completions ×2, code scanner, init file-changes,
telemetry readonly, upgrade lock), and the sandbox runs with full
capabilities, so they can't fail the way they expect.
- After the 429-any-method change (`7099e2b`), all other CLI test files
that mention 429 pass (1,195), apart from the 2 known sandbox permission
failures.

## Relation to #1669
This matches the retry policy in #1669 (task 5.0 and the
`sentry-api-transport` spec). Task 5.1 will move this behavior into
toolkit-core's shared transport unchanged.

<!-- junior-request-attribution:start -->
via **BYK**.
<!-- junior-request-attribution:end -->

<!-- junior-session-footer:start -->
<!-- junior-conversation-id:slack%3AC08J1NSPU6S%3A1792772794.175999 -->

--

[View Junior
Session](https://junior-prod.sentry.dev/conversations/slack%3AC08J1NSPU6S%3A1792772794.175999)
[[Sentry]](https://sentry.sentry.io/explore/conversations/slack%3AC08J1NSPU6S%3A1792772794.175999/?project=4510944073809921)

<!-- junior-session-footer:end -->

---------

Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com>
Co-authored-by: Burak Yigit Kaya <126780+BYK@users.noreply.github.com>
Co-Authored-By: BYK <burak.kaya@sentry.io>
BYK added a commit that referenced this pull request Oct 27, 2026
The CLI's fallback "immutable" cache tier kept any `/events/<id>/`,
`/trace/<id>/`, or `/trace-items/<id>/` response for 24 hours. It was
also checked before the `/issues/` volatile pattern, so `getLatestEvent`
(`/issues/<id>/events/latest/`) was cached for a day: re-running `sentry
issue view` an hour later could show an outdated "latest" event.

The getsentry/sentry#128818 spike
([findings](getsentry/sentry#128818 (comment)))
found that none of these responses are actually immutable. Event
payloads embed `nextEventID`/`previousEventID`, user reports, and
release data, reprocessing and merges can change them for the same ID,
`latest`/`oldest`/`recommended` resolve to different events over time,
and full traces gain spans as they arrive.

This removes the `immutable` tier:

| Tier | TTL | Matches |
| --- | --- | --- |
| no-cache | 0 | autofix and root-cause polling (unchanged) |
| volatile | 60 s | `/issues/` (now including issue events), project
event details, full traces, `dataset=logs\|spans`, trace logs |
| stable | 5 m | everything else, now including organization event
details and trace-item details |

Server `Cache-Control` headers still take precedence, so when
getsentry/sentry#128828 ships its `private, max-age=300` headers the CLI
follows them without a release. A TODO points at
getsentry/sentry-api-schema#81 and #102 for classifying by generated
`@sentry/api` operation metadata instead of URL regexes.

Notes for review:

- **No migration.** Freshness is recomputed from `createdAt` and the
current tier on read, so existing 24-hour entries on disk are treated by
the new rules immediately.
- **`immutableMinTimeToLive` removed.** It pointed at the deleted tier
constant. The `http-cache-semantics` default is the same 24 hours, and
it only applies to responses where the server sends `immutable`.
- **Cost:** repeated reads of project/issue events and full traces will
hit the API after 60 seconds instead of 24 hours; organization event and
trace-item details after 5 minutes.

Task group 2 of the `unify-sentry-api-layer` OpenSpec change in #1669.

**Checks run locally:**

- New `classifyUrl` cases plus fake-clock expiry tests for org event
details (hit at 4 min, miss at 6 min), issue latest event, project event
details, full traces, and server `max-age` overriding the fallback. 8 of
them fail against the old classifier.
- `pnpm exec vitest run test/lib/response-cache*.test.ts`: 60 passed
- CLI `typecheck`, `lint`, and `oxlint`/`oxfmt` on the changed files:
clean
- `pnpm run test:unit` (first revision): 11,583 passed, 6 failed. The
same 6 fail on unmodified `main` in my sandbox: filesystem-permission
tests (`completions`, `code-scanner`, `file-changes`, `telemetry`
readonly DB, `upgrade` lock) that depend on the environment, not this
change.

<!-- junior-session-footer:start -->
<!-- junior-conversation-id:local%3Aweb%3A0ab4fc2bd5a2e23f2c25efcf -->

--

[View Junior Session in
Sentry](https://sentry.sentry.io/explore/conversations/local%3Aweb%3A0ab4fc2bd5a2e23f2c25efcf/?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>
…tead

Matches #1681 as merged: the CLI has no separate detail tier, since it
would share the stable TTL. R2 eligibility on the hosted MCP becomes an
explicit URL allowlist owned by that store. Mark task group 2 done.

Co-Authored-By: BYK <burak.kaya@sentry.io>
BYK added a commit that referenced this pull request Oct 29, 2026
Follow-up to #1677 and #1673, and the last part of #1669 task 5.0. The
CLI's interim `retry-policy.ts` duplicated
`@sentry/toolkit-core/api-retry`, with small differences between the
two. The CLI now uses the shared module, so the CLI and MCP have one
rule for which statuses and methods get retried and how long a 429
waits. Each still sets its own limits: the CLI's `RETRY_POLICY` keeps 2
retries, a 1s base and a 10s per-wait cap, and adds a 30s total.

Changes in `toolkit-core/src/api-retry.ts`:

- New export `isIdempotentRequest(method, idempotent)`. The CLI uses it
for the timeout error wording and for `generate-api-schema`'s
idempotent-operation filter.
- `nextRetryDelayMs` now accepts `status: undefined` for a network error
or timeout, which is retried only for idempotent requests. The MCP
always passes a status, so its behavior is unchanged.

Behavior changes in the CLI, all from taking on the shared rule:

- **408 is no longer retried.** The shared list is 429/500/502/503/504,
as #1669's 5.0 spec says. Sentry doesn't send 408 itself.
- **Clock skew:** with a skewed local clock, the wait is now clamped
into the 1s window allowed by the response `Date` header, instead of
being measured from `Date`. One `sentry-client` test asserted the old
≥900ms wait; it now asserts that the wait stays inside that window and
not the hour of skew.
- **Retry-After:** when both `Retry-After` and the rate-limit reset are
present, `Retry-After` wins. Before, the CLI took the longer of the two.
- **Jitter:** a server-requested wait gets up to `baseDelayMs / 2` of
extra delay. Backoff without a server hint works the same as before.

Two things stay the same: 401 token refresh still works for any method,
and writes that Sentry marks idempotent (#1706) still retry.

<!-- junior-request-attribution:start -->
via **BYK**.
<!-- junior-request-attribution:end -->

<!-- junior-session-footer:start -->
<!-- junior-conversation-id:slack%3AC08J1NSPU6S%3A1793057920.879309 -->

--

[View Junior
Session](https://junior-prod.sentry.dev/conversations/slack%3AC08J1NSPU6S%3A1793057920.879309)
[[Sentry]](https://sentry.sentry.io/explore/conversations/slack%3AC08J1NSPU6S%3A1793057920.879309/?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>
sentry-junior Bot added a commit that referenced this pull request Oct 29, 2026
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>
BYK added a commit that referenced this pull request Oct 29, 2026
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)

- Done: 5.0 shared retry policy (#1673, #1710).
- This PR: 5.1 shared transport and 5.2 the CLI switch.
- Next, stacked on this branch and opened after it merges:
  - 5.3: a request-scoped fresh-read bypass.
- 6.1: `fetch` injection for the MCP. `SentryApiService` drops its own
retry loop so retries don't stack.

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.

<!-- 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>
sentry-junior Bot added a commit that referenced this pull request Oct 29, 2026
Add a `fresh` hook to the API transport. While it returns true, GETs skip
the cache lookup and go to the network, and the response still replaces
the cached entry, so later reads see it. Hosts back the hook with
request-scoped state, such as AsyncLocalStorage around a tool call.

Implements #1669 task 5.3.

Co-Authored-By: BYK <burak.kaya@sentry.io>
5.3 is in review in #1720, and 6.1 is stacked on it.

Co-Authored-By: BYK <burak.kaya@sentry.io>
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>
sentry-junior Bot added a commit that referenced this pull request Oct 29, 2026
Add `fetch` to McpServerOptions and ServerContext. SentryApiService sends
every request through it, including @sentry/api operations, so a host can
supply its authenticated transport. Without one, requests go through the
shared @sentry/toolkit-core/api-transport with REQUEST_RETRY_POLICY.

SentryApiService drops its own retry loop, so retries happen in one layer
and do not stack. ApiRateLimitError still takes retryAfterMs from the final
response's headers, and an abort during a retry wait keeps the caller's
reason.

The transport's credentials and timeouts become optional: without a
CredentialProvider it keeps the request's Authorization header, never
refreshes, and skips the cache.

Implements #1669 task 6.1.

Co-Authored-By: BYK <burak.kaya@sentry.io>
BYK added a commit that referenced this pull request Oct 29, 2026
Bumps `@sentry/api` from 0.423.0 to 0.425.0, the latest published
version. It changes the catalog entry and the `minimumReleaseAgeExclude`
entry in `pnpm-workspace.yaml`, plus the lockfile.

### What changes upstream

- **Pagination continuations** (sentry-api-schema#115, #116):
- `paginateUpTo` now returns a serializable `continuation`, and accepts
`resume`, so a batch can pick up inside a partly consumed page.
- `consumePage` and `CollectionPageFetcher` let a caller reuse a page it
already fetched.
- `BudgetedPageFetcher`'s second parameter is now the request budget. On
a replay it repeats the original budget.
- **New module:** `source-analysis`, which brings a new optional
`typescript` peer. The lockfile resolves it to the existing
`typescript@5.9.3`.
- Everything else is additive:
  - `types.gen.d.ts` is unchanged.
  - No exports were removed.

### Impact here

- **Nothing in this repo calls the changed helpers yet.** That covers
`paginateUpTo`, `paginateAll`, `consumePage` and `fetchPage`.
`unwrapPaginatedResult` is used in `cli/src/lib/api/alerts.ts` and
`dashboards.ts`, and its signature is unchanged. No code changes were
needed.
- **toolkit-core's own pagination** (`paginate` and `fetchAllPages` in
`toolkit-core/src/pagination.ts`) is a separate implementation and isn't
affected.
- It already resumes inside a page by writing the consumed rows into the
cursor's `is_prev` field. That keeps cursors as plain Sentry cursor
strings, which the API and older CLIs still accept.
- Moving it onto `@sentry/api`'s object-based continuation is a possible
follow-up for #1669. It would mean deciding how to serialize the
continuation for the CLI's `--cursor` and the MCP's cursor inputs, so I
left it out of this bump.

### Verification

- `pnpm install --frozen-lockfile` is clean.
- `tsc --noEmit` passes for `toolkit-core`, `mcp-core`, `mcp-server`,
`mcp-cloudflare` and `cli`. That includes the CLI's regenerated SDK,
which produces no diff.
- `toolkit-core`: 270 tests pass. `mcp-core`: 1,885 pass, 6 skipped.
- CLI unit tests: 11,760 pass. 7 fail in this sandbox:
- 6 are the file-permission tests that also fail on clean `main` here
(completions, telemetry, upgrade, code-scanner, file-changes).
- The 7th, `completions.property`, failed because the gitignored
`completion-targets.json` was stale after #1636. It passes after
`generate:completions`, and CI regenerates that file.

The lockfile needed one workaround. pnpm checks the existing lockfile
entries against `minimumReleaseAge` before it resolves anything. 0.423.0
is under the cutoff, and its exclusion is dropped in this PR, so a plain
`pnpm install` refused to run. I removed the old `@sentry/api` entries
and resolved again. The lockfile diff only swaps 0.423.0 for 0.425.0,
with the published integrity hash.

<!-- 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>
sentry-junior Bot added a commit that referenced this pull request Oct 29, 2026
Add `fetch` to McpServerOptions and ServerContext. SentryApiService sends
every request through it, including @sentry/api operations, so a host can
supply its authenticated transport. Without one, requests go through the
shared @sentry/toolkit-core/api-transport with REQUEST_RETRY_POLICY.

SentryApiService drops its own retry loop, so retries happen in one layer
and do not stack. ApiRateLimitError still takes retryAfterMs from the final
response's headers, and an abort during a retry wait keeps the caller's
reason.

The transport's credentials and timeouts become optional: without a
CredentialProvider it keeps the request's Authorization header, never
refreshes, and skips the cache.

Implements #1669 task 6.1.

Co-Authored-By: BYK <burak.kaya@sentry.io>
Step 1 consolidates the MCP's SentryApiService and the CLI's API modules
into one browser-safe client in toolkit-core, in one PR. Step 2 moves it
into @sentry/api. The host-supplied MCP fetch and sentry mcp on the CLI
session move to a deferred phase.

Co-Authored-By: BYK <burak.kaya@sentry.io>
The shared client takes host-supplied Standard Schema validators, so the
MCP keeps Zod and the CLI keeps valibot, and shared code depends on
neither at runtime.

Co-Authored-By: BYK <burak.kaya@sentry.io>
Dependencies run cli → mcp-core → toolkit-core, and mcp-cloudflare →
mcp-core; mcp-core never imports the CLI. The MCP shares the CLI SDK's
operations by moving them into the shared client, and the CLI's
host-only capabilities become optional interfaces that `sentry mcp`
injects. Task group 11 picks this up, alongside the @sentry/mcp-server
retirement and its lint and manifest cycle checks.

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>
Group 8 now ships as a stack split by layer: the foundation (#1732),
the transport-layer move, endpoint wrappers with the MCP switch, the
CLI host seam and switch, then merged shared wrappers. 6.1 is done in
#1722.

Co-Authored-By: BYK <burak.kaya@sentry.io>
BYK pushed a commit that referenced this pull request Oct 29, 2026
…se reading (#1732)

Starts the shared Sentry API client in `toolkit-core/src/api-client/`
(step 1 of #1669, task group 8). The MCP and the CLI both use it for
errors, API paths, and response reading.

It is the first of a stack of PRs by layer (see "Next" below).

## What changes

- **Portability guard (8.1).** Lint bans `node:*` imports and Node
globals such as `Buffer` and `process` in `toolkit-core`. A second
typecheck (`tsconfig.browser.json`) checks it against the DOM library
with no Node types, and `pnpm run tsc` runs both. The `node:zlib` cache
codec is the one exception. Its types move to `response-cache/body.ts`,
so the policy and store modules stay portable.
- **Standard Schema validation.** `validateResponse` takes any Standard
Schema validator: Zod from the MCP, valibot from the CLI. `toolkit-core`
depends only on `@standard-schema/spec` types. Lint bans `zod` and
`valibot` imports in its source; they are dev dependencies for the tests
only.
- **Shared errors and `apiPath`.** `ApiError`, its status subclasses,
`createApiError`, `UserInputError`, `ConfigurationError`, and `apiPath`
move from `mcp-core` to `@sentry/toolkit-core/api-client/*`.
`mcp-core/src/errors.ts` re-exports the two general errors, so its
importers don't change.
- **Shared response reading.** `readErrorResponse` reads a failed
response once: its text, parsed JSON, string `detail`, whether it is an
HTML page, and the rate-limit wait. `readJsonResponse` is the MCP's
Content-Type-checked JSON parse.
  - `SentryApiService` uses both.
  - The CLI's `throwRawApiError` uses `readErrorResponse`.
- Each product still maps the result to its own errors (MCP `ApiError`
subclasses, CLI exit-coded `ApiError`).
- **mcp-core builds ESM only.** Once the errors moved, the CJS build hit
a rolldown "canonical name not found" panic. Nothing loads that CJS
output:
  - `@sentry/mcp-core` is `private` and `"type": "module"`.
  - All 29 of its exports point at `.js`, with no `require` condition.
- Every consumer is in the workspace: `mcp-server` bundles it as a
devDependency through those `.js` exports, `mcp-cloudflare` bundles it
with Vite, and `mcp-test-client` and `mcp-server-evals` are private ESM
packages.
  - No source requires it or reads its `.cjs` files.

`@sentry/mcp-server`'s own CJS and ESM output is unchanged. I tried
upgrading tsdown first, to 0.15.12, 0.18.4, 0.20.3, 0.22.14, and 0.23.4.
Every version broke the `mcp-server-mocks` build: 0.15.12 failed
declaration output (`members is not iterable`), and 0.18.4 and later hit
a rolldown JSON parse "recursion limit exceeded" on
`src/fixtures/trace.json`. 0.18.4 and 0.20.3 also failed the `mcp-core`
and `mcp-server` builds. Bundling toolkit-core into the dist
(`noExternal`) would have duplicated the error classes across dists and
broken `instanceof` between packages.
- **Docs.** The `toolkit-core` README now has an "API client" section.
`api-patterns.md`, `error-handling.md`, and the logging skill point at
the new error module.

## Behavior changes

- **MCP, unreadable error body.** If the body of a failed response
cannot be read, the caller now gets an `ApiError` for the status instead
of the raw stream error.
- **MCP, "no string detail" warning.** The `validationErrorMessage` on
the "Failed to parse validated API error response" warning is now a
fixed string instead of Zod's message. `ApiErrorSchema` is deleted;
nothing else used it.

Messages, details, `retryAfterMs`, and the CLI's error detail are
otherwise unchanged.

## Next

This is the bottom of a stack of PRs by layer for the rest of #1669
group 8. Each one is based on the PR below it:

1. Move pagination, cache policy, retry, and transport under
`api-client/`.
2. Shared endpoint wrappers on top of `@sentry/api`, with
`SentryApiService` switched onto them.
3. The CLI host seam, with the CLI's `lib/api/` wrappers switched onto
the shared client.
4. Merge the wrappers both products share.

This PR does not touch `packages/mcp-server`,
`packages/cli/src/lib/mcp.ts`, or the evals stdio entry; the
`@sentry/mcp-server` retirement PR owns those.

## Commits

Each codemod run is a generated commit whose message gives the command,
and each hand fix is a separate commit:

1. `13053adab` Portability guard (hand)
2. `f13df0d24` Standard Schema validation (hand)
3. `bea21d648` mcp-core ESM only (hand)
4. `0d083bdf3` Move errors and `apiPath` (**generated**,
`move-shared-errors.mjs`)
5. `4ddc54813` Move the `client.ts` request-path test back next to
`client.ts` (hand)
6. `143da6e39` Shared response reading (hand)
7. `b776eb55b` MCP JSON parsing on the shared reader (**generated**,
`use-shared-json-response.mjs`)
8. `44976334d` MCP error responses on the shared reader (hand)
9. `7027a482d` CLI raw error responses on the shared reader (hand)
10. `bf0b248ac` Docs (hand)

## Codemods

The scripts are not in the tree. To re-run one, check out the parent of
its generated commit, run the script from a scratch directory with
`ts-morph@28.0.0` installed, then run oxfmt on the changed files. The
output should match the commit.

<details>
<summary><code>move-shared-errors.mjs</code> (commit
0d083bd)</summary>

```sh
node move-shared-errors.mjs <repo-root>
oxfmt <added and modified files>
```

```js
// Codemod: move the MCP's API error classes and API path helper into
// toolkit-core's shared api-client, and rewrite every importer.
//
// Usage: node move-shared-errors.mjs <toolkit-repo-root>
// (run from a directory where `ts-morph@28.0.0` is installed)
import { realpathSync } from "node:fs";
import path from "node:path";
import { Project } from "ts-morph";

const root = path.resolve(process.argv[2] ?? ".");
const mcpSrc = path.join(root, "packages/mcp-core/src");
const coreSrc = path.join(root, "packages/toolkit-core/src");

// Classes the shared client throws, moved out of mcp-core's general errors.
const MOVED_CLASSES = ["UserInputError", "ConfigurationError"];
// mcp-core files that move into toolkit-core/src/api-client/.
const MOVED_FILES = [
  "api-client/errors.ts",
  "api-client/api-path.ts",
  "api-client/api-path.test.ts",
];

const project = new Project({
  tsConfigFilePath: path.join(root, "packages/mcp-core/tsconfig.json"),
});
project.addSourceFilesAtPaths(path.join(coreSrc, "**/*.ts"));

// 1. Move the classes into the API errors module; mcp-core/src/errors.ts
//    re-exports them so its existing importers keep working.
const generalErrors = project.getSourceFileOrThrow(
  path.join(mcpSrc, "errors.ts"),
);
const apiErrors = project.getSourceFileOrThrow(
  path.join(mcpSrc, "api-client/errors.ts"),
);
const classTexts = MOVED_CLASSES.map((name) => {
  const declaration = generalErrors.getClassOrThrow(name);
  const text = declaration.getFullText().trim();
  declaration.remove();
  return text;
});
apiErrors.insertText(0, `${classTexts.join("\n\n")}\n\n`);
generalErrors.insertExportDeclaration(0, {
  namedExports: [...MOVED_CLASSES].sort(),
  moduleSpecifier: "./api-client/errors",
});

// 2. Move the files. ts-morph rewrites relative imports in and to them.
const movedPaths = new Set();
for (const file of MOVED_FILES) {
  const sourceFile = project.getSourceFileOrThrow(path.join(mcpSrc, file));
  sourceFile.move(path.join(coreSrc, "api-client", path.basename(file)));
  movedPaths.add(sourceFile.getFilePath());
}

// Moved files take the moved classes from the API errors module directly,
// not through mcp-core's re-export.
for (const movedPath of movedPaths) {
  const sourceFile = project.getSourceFileOrThrow(movedPath);
  for (const declaration of sourceFile.getImportDeclarations()) {
    if (declaration.getModuleSpecifierSourceFile() !== generalErrors) continue;
    const names = declaration.getNamedImports().map((n) => n.getName());
    if (names.every((name) => MOVED_CLASSES.includes(name))) {
      declaration.setModuleSpecifier(apiErrors);
    }
  }
}

// 3. Normalize specifiers that point into toolkit-core: relative with `.js`
//    inside toolkit-core, `@sentry/toolkit-core/<path>` everywhere else.
function realPath(file) {
  try {
    return realpathSync(file);
  } catch {
    return file;
  }
}

for (const sourceFile of project.getSourceFiles()) {
  const importer = sourceFile.getFilePath();
  const declarations = [
    ...sourceFile.getImportDeclarations(),
    ...sourceFile.getExportDeclarations(),
  ];
  for (const declaration of declarations) {
    const target = declaration.getModuleSpecifierSourceFile();
    if (!target) continue;
    const targetPath = realPath(target.getFilePath());
    if (!targetPath.startsWith(`${coreSrc}/`)) continue;
    // Leave specifiers the codemod did not affect as they are.
    if (!movedPaths.has(importer) && !movedPaths.has(targetPath)) continue;
    const withoutExt = targetPath.replace(/\.ts$/, "");
    let specifier;
    if (importer.startsWith(`${coreSrc}/`)) {
      specifier = path.relative(path.dirname(importer), withoutExt);
      if (!specifier.startsWith(".")) specifier = `./${specifier}`;
      specifier = `${specifier}.js`;
    } else {
      specifier = `@sentry/toolkit-core/${path
        .relative(coreSrc, withoutExt)
        .replace(/\/index$/, "")}`;
    }
    if (declaration.getModuleSpecifierValue() !== specifier) {
      declaration.setModuleSpecifier(specifier);
    }
  }
}

await project.save();
```

</details>

<details>
<summary><code>use-shared-json-response.mjs</code> (commit
b776eb5)</summary>

```sh
node use-shared-json-response.mjs <repo-root>
oxfmt packages/mcp-core/src/api-client/client.ts
```

```js
// Codemod: replace SentryApiService's private parseJsonResponse with the
// shared readJsonResponse from toolkit-core, and delete the private copy.
//
// Usage: node use-shared-json-response.mjs <toolkit-repo-root>
// (run from a directory where `ts-morph@28.0.0` is installed)
import path from "node:path";
import { Project, SyntaxKind } from "ts-morph";

const root = path.resolve(process.argv[2] ?? ".");
const clientPath = path.join(root, "packages/mcp-core/src/api-client/client.ts");

const project = new Project({ skipAddingFilesFromTsConfig: true });
const client = project.addSourceFileAtPath(clientPath);
const service = client.getClassOrThrow("SentryApiService");

// 1. `this.parseJsonResponse(x)` -> `readJsonResponse(x)`.
const calls = client
  .getDescendantsOfKind(SyntaxKind.CallExpression)
  .filter((call) => call.getExpression().getText() === "this.parseJsonResponse")
  .reverse();
for (const call of calls) {
  call.getExpression().replaceWithText("readJsonResponse");
}

// 2. Delete the private method it replaces.
service.getMethodOrThrow("parseJsonResponse").remove();

// 3. Import the shared helper.
client.addImportDeclaration({
  namedImports: ["readJsonResponse"],
  moduleSpecifier: "@sentry/toolkit-core/api-client/response",
});

await project.save();
console.log(`rewrote ${calls.length} call sites`);
```

</details>

## Verification

At `bf0b248ac`, rebased on `main` after #1722 merged:

- `pnpm run tsc` passes. It includes toolkit-core's browser-target
typecheck and the CLI typecheck.
- `pnpm run lint` passes (oxlint, oxfmt, ast-grep rules), and so does
`pnpm run docs:check`.
- Tests: toolkit-core 306, mcp-core 1881 (6 skipped), and mcp-cloudflare
443 pass.
- Before the rebase, the full `pnpm run test` also passed mcp-server
(87), mcp-test-client (80), docs-mcp (49), local (322), and cli-docs.
- CLI (`test/lib`, `test/commands`): 11718 pass and 6 fail in 5 files
(`completions`, `dsn/code-scanner`, `init/tools/file-changes`,
`telemetry`, `upgrade`).
- All 6 test permission errors (EACCES/EPERM/read-only). They fail only
because the sandbox runs with `CAP_DAC_OVERRIDE`, so a `chmod 000` file
stays readable.
  - This PR touches none of that code; CI is the check for them.
- Both generated commits reproduce exactly when their codemod is re-run
on the rebased parent.
- The diff has no codemod scripts, ast-grep or ts-morph config, or
ts-morph dependency.

<!-- 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>
… 8.3 and 8.4

Co-Authored-By: BYK <burak.kaya@sentry.io>
BYK added a commit that referenced this pull request Oct 29, 2026
… api-client (#1736)

Moves pagination, the response-cache policy, retry, and transport under
`toolkit-core/src/api-client/`, the layout they will have inside
`@sentry/api` (#1669, task 8.3). Every importer is rewritten. Nothing
else changes.

It's the second PR in the group 8 stack. #1732, the foundation, has
merged, so this one targets `main`.

## What changes

- **Moves.** Only the relative imports inside the moved files change:
  - `pagination.ts` → `api-client/pagination.ts`
  - `api-retry.ts` → `api-client/retry.ts`
  - `api-transport.ts` → `api-client/transport.ts`
-
`response-cache/{body,index,invalidation,keys,memory-store,policy,store,tiers}.ts`
→ `api-client/response-cache/`
  - Their tests move with them.
- **Stays put:** `response-cache/codec.ts`. It's the `node:zlib` codec,
host storage that won't move into `@sentry/api`. It's still excluded
from the browser typecheck, and the `./response-cache/codec` export
doesn't change.
- **Exports:** `./pagination`, `./api-retry`, `./api-transport`, and
`./response-cache` are replaced by the existing `./api-client/*`
wildcard plus one `./api-client/response-cache` entry for the directory
index. `@sentry/toolkit-core` is `private`, and every consumer is in
this workspace and rewritten here.
- **Importers.** The codemod rewrites 11 import specifiers across the
CLI (including `script/generate-api-schema.ts`),
`mcp-core/src/api-client/client.ts`, and toolkit-core.
- **Docs:** `api-patterns.md`, the toolkit-core README, and code
comments name the new subpaths.

## Behavior changes

None.

This PR does not touch `packages/mcp-server`,
`packages/cli/src/lib/mcp.ts`, or the evals stdio entry. The
`@sentry/mcp-server` retirement PR owns those.

## Commits

1. `85fae9151` Move the modules and rewrite importers (**generated**,
`move-transport-layer.mjs`)
2. `85df504be` Point comments and docs at the new paths (hand)

## Verification

- The generated commit reproduces exactly: check out `origin/main`
(`9409280c1`), run the command below, then oxfmt on the changed files.
The tree matches `85fae9151`.
- `pnpm run tsc` (including toolkit-core's browser typecheck), `pnpm run
lint`, and `node scripts/check-doc-links.mjs` pass.
- Tests: toolkit-core 306 passed; mcp-core 1881 passed, 6 skipped;
mcp-cloudflare 443 passed; mcp-server 87 passed; CLI `test/lib
test/commands test/script` 11895 passed, 46 skipped, **6 failed**.
- The 6 CLI failures are sandbox-only, and they fail the same way
without this change. The sandbox runs with `CAP_DAC_OVERRIDE`, so these
permission tests can't make a file unreadable: `completions` (EACCES,
EPERM), `dsn/code-scanner` (unreadable file), `init/tools/file-changes`
(I/O failure), `telemetry` (read-only DB), and `upgrade` (`acquireLock`
permission error). CI runs them normally.

## Codemod

The script isn't in the tree. To re-run it, check out the parent of the
generated commit and run it from a scratch directory with
`ts-morph@28.0.0` installed. Then run oxfmt on the added, modified, and
renamed package files.

<details>
<summary><code>move-transport-layer.mjs</code> (commit
85fae91)</summary>

```sh
node move-transport-layer.mjs <repo-root>
git add -A -- packages
oxfmt $(git diff --cached --name-only --diff-filter=AMR)
```

```js
// Codemod: move toolkit-core's pagination, response-cache policy, api-retry,
// and api-transport modules under src/api-client/, and rewrite every
// importer and the package's exports. The node:zlib cache codec stays at
// src/response-cache/codec.ts as a host-side module.
//
// Usage: node move-transport-layer.mjs <toolkit-repo-root>
// (run from a directory where `ts-morph@28.0.0` is installed)
import { readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { Node, Project } from "ts-morph";

const root = path.resolve(process.argv[2] ?? ".");
const coreDir = path.join(root, "packages/toolkit-core");
const coreSrc = path.join(coreDir, "src");

// Old path -> new path, relative to toolkit-core/src.
const MOVES = new Map([
  ["pagination.ts", "api-client/pagination.ts"],
  ["pagination.test.ts", "api-client/pagination.test.ts"],
  ["api-retry.ts", "api-client/retry.ts"],
  ["api-retry.test.ts", "api-client/retry.test.ts"],
  ["api-transport.ts", "api-client/transport.ts"],
  ["api-transport.test.ts", "api-client/transport.test.ts"],
]);
const CACHE_FILES = [
  "body.ts",
  "index.ts",
  "invalidation.ts",
  "invalidation.test.ts",
  "keys.ts",
  "keys.test.ts",
  "memory-store.ts",
  "memory-store.test.ts",
  "policy.ts",
  "policy.test.ts",
  "policy.property.test.ts",
  "store.ts",
  "tiers.ts",
  "tiers.test.ts",
];
for (const file of CACHE_FILES) {
  MOVES.set(`response-cache/${file}`, `api-client/response-cache/${file}`);
}

// Package subpath -> new subpath. `response-cache/codec` does not move.
const SPECIFIERS = new Map([
  ["@sentry/toolkit-core/api-retry", "@sentry/toolkit-core/api-client/retry"],
  [
    "@sentry/toolkit-core/api-transport",
    "@sentry/toolkit-core/api-client/transport",
  ],
  [
    "@sentry/toolkit-core/pagination",
    "@sentry/toolkit-core/api-client/pagination",
  ],
  [
    "@sentry/toolkit-core/response-cache",
    "@sentry/toolkit-core/api-client/response-cache",
  ],
]);

// 1. Move the files inside toolkit-core. ts-morph rewrites relative imports
//    in and to them; then give every rewritten specifier the `.js` suffix
//    the package uses.
const core = new Project({
  tsConfigFilePath: path.join(coreDir, "tsconfig.json"),
});
const moved = new Set();
for (const [from, to] of MOVES) {
  const sourceFile = core.getSourceFileOrThrow(path.join(coreSrc, from));
  sourceFile.move(path.join(coreSrc, to));
  moved.add(sourceFile.getFilePath());
}
for (const sourceFile of core.getSourceFiles()) {
  const importer = sourceFile.getFilePath();
  for (const declaration of [
    ...sourceFile.getImportDeclarations(),
    ...sourceFile.getExportDeclarations(),
  ]) {
    const target = declaration.getModuleSpecifierSourceFile();
    if (!target || !declaration.getModuleSpecifierValue()?.startsWith(".")) {
      continue;
    }
    const targetPath = target.getFilePath();
    if (!moved.has(importer) && !moved.has(targetPath)) continue;
    let specifier = path.relative(
      path.dirname(importer),
      targetPath.replace(/\.ts$/, ""),
    );
    if (!specifier.startsWith(".")) specifier = `./${specifier}`;
    declaration.setModuleSpecifier(`${specifier}.js`);
  }
}
await core.save();

// 2. Rewrite package specifiers everywhere else, including dynamic imports
//    and vi.mock() calls: any string literal equal to an old subpath.
const others = new Project({ skipAddingFilesFromTsConfig: true });
others.addSourceFilesAtPaths([
  path.join(root, "{packages,apps}/*/{src,test,script,scripts}/**/*.{ts,tsx,mts}"),
  `!${path.join(root, "**/node_modules/**")}`,
  `!${path.join(root, "**/dist/**")}`,
  `!${path.join(coreDir, "**")}`,
]);
let rewritten = 0;
for (const sourceFile of others.getSourceFiles()) {
  let changed = false;
  sourceFile.forEachDescendant((node) => {
    if (!Node.isStringLiteral(node)) return;
    const next = SPECIFIERS.get(node.getLiteralValue());
    if (next) {
      node.setLiteralValue(next);
      changed = true;
      rewritten += 1;
    }
  });
  if (changed) await sourceFile.save();
}

// 3. Point the package exports at the new paths. The `./api-client/*`
//    wildcard covers pagination, retry, and transport; the cache needs its
//    index.
const manifestPath = path.join(coreDir, "package.json");
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
const exports = {};
for (const [key, value] of Object.entries(manifest.exports)) {
  if (["./api-retry", "./api-transport", "./pagination", "./response-cache"].includes(key)) {
    continue;
  }
  if (key === "./api-client/*") {
    exports["./api-client/response-cache"] = {
      types: "./src/api-client/response-cache/index.ts",
      default: "./src/api-client/response-cache/index.ts",
    };
  }
  exports[key] = value;
}
manifest.exports = exports;
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);

console.log(`moved ${moved.size} files, rewrote ${rewritten} specifiers`);
```

</details>

<!-- 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 — 5af8eb63 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: low PR risk score: low

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant