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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,15 @@ self-hosted deployment that does run Seer, opt in with `MCP_SKILLS`:
}
```

### Rate Limits

Tool calls are subject to the Sentry API's rate limits. The MCP server retries
rate-limited requests, and transient Sentry server errors on read requests,
after a short wait. When a limit lasts longer, the tool returns a **Rate
Limited** message that says when to try again. Agents hit the per-second
limits most often by running many Sentry tool calls in parallel, so run them
one at a time if you see this message.

### MCP Inspector

MCP includes an [Inspector](https://modelcontextprotocol.io/docs/tools/inspector), to easily test the service:
Expand Down
35 changes: 26 additions & 9 deletions docs/contributing/api-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ fail to resolve for legacy mixed-case project slugs; keep them for display.
Organization discovery uses `/api/0/organizations/`; a limit over 100 follows
bounded pages through Sentry's Link cursors. Organization, team, and project
lists use `@sentry/api` operations to build GET paths and queries. The MCP
client still owns the request transport, token handling, GET retries, error
client still owns the request transport, token handling, retries, error
types, and Zod response validation. Public SaaS hosts use `sentry.io` to list
organizations across regions. Single-tenant hosts under `*.my.sentry.io` and
self-hosted instances keep their configured host for this request.
Expand Down Expand Up @@ -252,18 +252,35 @@ See error patterns in [common-patterns.md](common-patterns.md#error-handling).

### Automatic Retries

`SentryApiService.request()` transparently retries transient upstream gateway
failures (`502`/`503`/`504`) using exponential backoff. Retries are:

- **GET-only** — non-idempotent methods (`POST`/`PUT`/`DELETE`) are never
retried, so an automatic retry can never replay a mutation.
- **Gateway-only** — gated on `ApiServerError.isGatewayError()`. Persistent
`500`s, `4xx` client errors, and network/configuration errors fail fast.
`SentryApiService.request()` transparently retries failed requests using the
Comment thread
BYK marked this conversation as resolved.
shared policy in `@sentry/toolkit-core/api-retry`. This page describes the
behavior; the values live only in code, so read them there instead of copying
them here:

- Which statuses retry, and for which methods: `RETRYABLE_STATUSES` in
[`api-retry.ts`](../../packages/toolkit-core/src/api-retry.ts). In short, a
rate-limited request retries for any method, a transient server error only
for an idempotent one, and other errors never.
- How many retries and how long they may wait: `REQUEST_RETRY_POLICY` in
[`client.ts`](../../packages/mcp-core/src/api-client/client.ts).

Retries are:

- **Rate-limit aware** — a `429` waits for its `Retry-After` header, or for
Sentry's `X-Sentry-Rate-Limit-Reset`. Concurrency-limit `429`s and `5xx`
errors use jittered exponential backoff.
- **Capped** — by retry count, single wait, and total wait. A reset further
out than the single-wait cap fails at once with an `ApiRateLimitError` whose
`retryAfterMs` and user message say when to retry.
- **Abortable** — aborting the request's `signal` ends the wait.
- **Fail-fast otherwise** — network and configuration errors are not retried.
A rate-limited tool call returns a **Rate Limited** message with the retry
time, not an input error.
- **Bypassed by `allowStatuses`** — a status the caller opts into is returned
as-is from `requestOnce`, never retried.

Callers and tools need no changes: a retried request either resolves normally or
throws the same `ApiServerError` it would have thrown without retries.
throws the same error it would have thrown without retries.

## Best Practices

Expand Down
215 changes: 213 additions & 2 deletions packages/mcp-core/src/api-client/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { SentryApiService } from "./client";
import {
ApiNotFoundError,
ApiPermissionError,
ApiRateLimitError,
ApiServerError,
ApiValidationError,
} from "./errors";
Expand Down Expand Up @@ -1442,8 +1443,32 @@ describe("transient 5xx retry", () => {
expect(fetchMock).toHaveBeenCalledTimes(3);
});

it("does not retry a non-gateway 500", async () => {
const fetchMock = vi.fn().mockResolvedValue(internalError());
it("retries a 500 on a GET", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(internalError())
.mockResolvedValueOnce(authOk());
globalThis.fetch = fetchMock;

const apiService = new SentryApiService({
host: "sentry.io",
accessToken: "test-token",
});

const promise = apiService.getAuthenticatedUser();
await vi.runAllTimersAsync();

expect((await promise).id).toBe("1");
expect(fetchMock).toHaveBeenCalledTimes(2);
});

it("does not retry a deterministic 5xx such as 501", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ detail: "Not Implemented" }), {
status: 501,
headers: { "Content-Type": "application/json" },
}),
);
globalThis.fetch = fetchMock;

const apiService = new SentryApiService({
Expand Down Expand Up @@ -1503,6 +1528,192 @@ describe("transient 5xx retry", () => {
});
});

describe("rate limit retry", () => {
let originalFetch: typeof globalThis.fetch;
const now = Date.UTC(2026, 0, 1, 12, 0, 0, 300);
const nowSeconds = Math.floor(now / 1000);

// Shaped like Sentry's rate-limit middleware response for a fixed window.
const rateLimited = (resetSeconds: number, headers = {}) =>
new Response(
JSON.stringify({
detail:
"You are attempting to use this endpoint too frequently. Limit is 40 requests in 1 seconds",
}),
{
status: 429,
statusText: "Too Many Requests",
headers: {
"Content-Type": "application/json",
"X-Sentry-Rate-Limit-Remaining": "0",
"X-Sentry-Rate-Limit-Limit": "40",
"X-Sentry-Rate-Limit-Reset": String(resetSeconds),
...headers,
},
},
);

const authOk = () =>
new Response(
JSON.stringify({ id: "1", name: "Test User", email: "test@example.com" }),
{ status: 200, headers: { "Content-Type": "application/json" } },
);

const rawRequest = (apiService: SentryApiService) =>
(
apiService as unknown as {
request: (path: string, options?: RequestInit) => Promise<Response>;
}
).request.bind(apiService);

const apiService = () =>
new SentryApiService({ host: "sentry.io", accessToken: "test-token" });

beforeEach(() => {
originalFetch = globalThis.fetch;
vi.useFakeTimers({ now });
});

afterEach(() => {
globalThis.fetch = originalFetch;
vi.useRealTimers();
});

it("waits for the rate-limit reset and retries a GET", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(rateLimited(nowSeconds + 1))
.mockResolvedValueOnce(authOk());
globalThis.fetch = fetchMock;

const promise = apiService().getAuthenticatedUser();
// The reset is 700ms away; no retry before then.
await vi.advanceTimersByTimeAsync(699);
expect(fetchMock).toHaveBeenCalledTimes(1);
await vi.runAllTimersAsync();

expect((await promise).id).toBe("1");
expect(fetchMock).toHaveBeenCalledTimes(2);
});

it("retries a concurrency-limit 429 with backoff", async () => {
const concurrencyLimited = new Response(
JSON.stringify({
detail:
"You are attempting to go above the allowed concurrency for this endpoint. Concurrency limit is 25",
}),
{
status: 429,
headers: {
"Content-Type": "application/json",
"X-Sentry-Rate-Limit-Remaining": "30",
"X-Sentry-Rate-Limit-Reset": String(nowSeconds + 1),
"X-Sentry-Rate-Limit-ConcurrentRemaining": "0",
"X-Sentry-Rate-Limit-ConcurrentLimit": "25",
},
},
);
const fetchMock = vi
.fn()
.mockResolvedValueOnce(concurrencyLimited)
.mockResolvedValueOnce(authOk());
globalThis.fetch = fetchMock;

const promise = apiService().getAuthenticatedUser();
// The first backoff step is at most 250ms, well before the window reset.
await vi.advanceTimersByTimeAsync(250);

expect((await promise).id).toBe("1");
expect(fetchMock).toHaveBeenCalledTimes(2);
});

it("gives up after the retry budget with the delay in the message", async () => {
const fetchMock = vi
.fn()
.mockImplementation(() => rateLimited(Math.floor(Date.now() / 1000) + 1));
globalThis.fetch = fetchMock;

const promise = apiService().getAuthenticatedUser();
promise.catch(() => {});
await vi.runAllTimersAsync();

const error = await promise.catch((e: unknown) => e);
expect(error).toBeInstanceOf(ApiRateLimitError);
expect((error as ApiRateLimitError).toUserMessage()).toMatch(
/Retry in 1 second\.$/,
);
// Initial attempt + 2 retries.
expect(fetchMock).toHaveBeenCalledTimes(3);
});

it("does not wait for a reset beyond the per-wait cap", async () => {
const fetchMock = vi.fn().mockResolvedValue(rateLimited(nowSeconds + 30));
globalThis.fetch = fetchMock;

const error = await apiService()
.getAuthenticatedUser()
.catch((e: unknown) => e);

expect(error).toBeInstanceOf(ApiRateLimitError);
expect((error as ApiRateLimitError).retryAfterMs).toBe(29_700);
expect((error as ApiRateLimitError).toUserMessage()).toBe(
"API error (429): You are attempting to use this endpoint too frequently. Limit is 40 requests in 1 seconds. Retry in 30 seconds.",
);
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it("honors a proxy's Retry-After over the Sentry reset", async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(rateLimited(nowSeconds + 1, { "Retry-After": "120" }));
globalThis.fetch = fetchMock;

const error = await apiService()
.getAuthenticatedUser()
.catch((e: unknown) => e);

expect((error as ApiRateLimitError).retryAfterMs).toBe(120_000);
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it("retries a rate-limited POST, which Sentry rejected before running it", async () => {
const fetchMock = vi
.fn()
.mockImplementation(() => rateLimited(Math.floor(Date.now() / 1000) + 1));
globalThis.fetch = fetchMock;

const promise = apiService().createTeam({
organizationSlug: "my-org",
name: "My Team",
});
promise.catch(() => {});
await vi.runAllTimersAsync();

await expect(promise).rejects.toBeInstanceOf(ApiRateLimitError);
expect(fetchMock).toHaveBeenCalledTimes(3);
for (const [, init] of fetchMock.mock.calls) {
expect(init.method).toBe("POST");
}
});

it("stops waiting when the request is aborted", async () => {
const fetchMock = vi.fn().mockResolvedValue(rateLimited(nowSeconds + 1));
globalThis.fetch = fetchMock;
const controller = new AbortController();
const reason = new Error("tool call cancelled");

const promise = rawRequest(apiService())("/auth/", {
signal: controller.signal,
});
promise.catch(() => {});
await vi.advanceTimersByTimeAsync(100);
controller.abort(reason);

await expect(promise).rejects.toBe(reason);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});

describe("request headers", () => {
let originalFetch: typeof globalThis.fetch;

Expand Down
Loading
Loading