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
11 changes: 7 additions & 4 deletions docs/contributing/api-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,8 +260,11 @@ See error patterns in [common-patterns.md](common-patterns.md#error-handling).

### Automatic Retries

`SentryApiService.request()` transparently retries failed requests using the
shared policy in `@sentry/toolkit-core/api-retry`. This page describes the
`SentryApiService` sends every request, including `@sentry/api` operations,
through the shared transport in `@sentry/toolkit-core/api-transport`, which
retries failed requests using the shared policy in
`@sentry/toolkit-core/api-retry`. `SentryApiService` never retries on top, so
a request is retried by one layer only. This page describes the
behavior; the values live only in code, so read them there instead of copying
them here:

Expand All @@ -288,8 +291,8 @@ Retries are:
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.
- **Before `allowStatuses`** — a status the caller opts into is returned
as-is, after the transport has finished retrying it.

Callers and tools need no changes: a retried request either resolves normally or
throws the same error it would have thrown without retries.
Expand Down
11 changes: 4 additions & 7 deletions packages/mcp-cloudflare/src/server/lib/mcp-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -634,13 +634,10 @@ describe("MCP Handler", () => {
]);
expect(env.MCP_CACHE.put).not.toHaveBeenCalled();
expect(apiFetch).toHaveBeenCalledOnce();
expect(apiFetch).toHaveBeenCalledWith(
profileUrl,
expect.objectContaining({
headers: expect.objectContaining({
Authorization: `Bearer ${ctx.props.accessToken}`,
}),
}),
const [url, init] = apiFetch.mock.calls[0] ?? [];
expect(url).toBe(profileUrl);
expect(new Headers(init?.headers).get("Authorization")).toBe(
`Bearer ${ctx.props.accessToken}`,
);
} finally {
apiFetch.mockRestore();
Expand Down
99 changes: 85 additions & 14 deletions packages/mcp-core/src/api-client/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1503,7 +1503,7 @@ describe("transient 5xx retry", () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it("does not retry a 5xx that the caller opted into via allowStatuses", async () => {
it("returns an allowed 5xx as-is once the transport stops retrying", async () => {
const fetchMock = vi.fn().mockResolvedValue(serviceUnavailable());
globalThis.fetch = fetchMock;

Expand All @@ -1513,8 +1513,8 @@ describe("transient 5xx retry", () => {
});

// getEventAttachment passes allowStatuses so the caller can inspect the raw
// response; such requests must be returned as-is, never retried.
const response = await (
// response. Retries happen in the transport, before the status is checked.
const pending = (
apiService as unknown as {
request: (
path: string,
Expand All @@ -1523,9 +1523,10 @@ describe("transient 5xx retry", () => {
) => Promise<Response>;
}
).request("/auth/", undefined, { allowStatuses: [503] });
await vi.runAllTimersAsync();

expect(response.status).toBe(503);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect((await pending).status).toBe(503);
expect(fetchMock).toHaveBeenCalledTimes(3);
});
});

Expand Down Expand Up @@ -1780,6 +1781,20 @@ describe("rate limit retry", () => {
}
});

it("sends a still-limited GET at most maxRetries + 1 times", async () => {
const fetchMock = vi
.fn()
.mockImplementation(async () => rateLimited(nowSeconds + 1));
globalThis.fetch = fetchMock;

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

await expect(promise).rejects.toBeInstanceOf(ApiRateLimitError);
expect(fetchMock).toHaveBeenCalledTimes(3);
});

it("stops waiting when the request is aborted", async () => {
const fetchMock = vi.fn().mockResolvedValue(rateLimited(nowSeconds + 1));
globalThis.fetch = fetchMock;
Expand All @@ -1796,6 +1811,30 @@ describe("rate limit retry", () => {
await expect(promise).rejects.toBe(reason);
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it("keeps the caller's abort reason for an in-flight request", async () => {
const fetchMock = vi.fn(
(_input: Request | string | URL, init?: RequestInit) =>
new Promise<Response>((_, reject) => {
init?.signal?.addEventListener("abort", () =>
reject(init.signal?.reason),
);
}),
);
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.waitFor(() => expect(fetchMock).toHaveBeenCalledOnce());
controller.abort(reason);

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

describe("request headers", () => {
Expand Down Expand Up @@ -1837,12 +1876,18 @@ describe("request headers", () => {

const [, requestInit] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock
.calls[0];
expect(requestInit.headers["X-Sentry-MCP-Client-Id"]).toBe("abc123");
expect(requestInit.headers["X-Sentry-MCP-Client-Name"]).toBe("Claude Code");
expect(requestInit.headers["X-Sentry-MCP-Client-Family"]).toBe(
"claude-code",
expect(new Headers(requestInit.headers).get("X-Sentry-MCP-Client-Id")).toBe(
"abc123",
);
expect(requestInit.headers["X-Sentry-MCP-Utm-Source"]).toBe("plugin");
expect(
new Headers(requestInit.headers).get("X-Sentry-MCP-Client-Name"),
).toBe("Claude Code");
expect(
new Headers(requestInit.headers).get("X-Sentry-MCP-Client-Family"),
).toBe("claude-code");
expect(
new Headers(requestInit.headers).get("X-Sentry-MCP-Utm-Source"),
).toBe("plugin");
});

it("should not send MCP client headers when clientId and clientName are not set", async () => {
Expand All @@ -1869,10 +1914,18 @@ describe("request headers", () => {

const [, requestInit] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock
.calls[0];
expect(requestInit.headers["X-Sentry-MCP-Client-Id"]).toBeUndefined();
expect(requestInit.headers["X-Sentry-MCP-Client-Name"]).toBeUndefined();
expect(requestInit.headers["X-Sentry-MCP-Client-Family"]).toBeUndefined();
expect(requestInit.headers["X-Sentry-MCP-Utm-Source"]).toBeUndefined();
expect(
new Headers(requestInit.headers).get("X-Sentry-MCP-Client-Id"),
).toBeNull();
expect(
new Headers(requestInit.headers).get("X-Sentry-MCP-Client-Name"),
).toBeNull();
expect(
new Headers(requestInit.headers).get("X-Sentry-MCP-Client-Family"),
).toBeNull();
expect(
new Headers(requestInit.headers).get("X-Sentry-MCP-Utm-Source"),
).toBeNull();
});
});

Expand Down Expand Up @@ -2526,6 +2579,7 @@ describe("API query builders", () => {
// Mock the API response
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: {
get: (key: string) =>
key === "content-type" ? "application/json" : null,
Expand Down Expand Up @@ -2565,6 +2619,7 @@ describe("API query builders", () => {
// Mock the API response
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: {
get: (key: string) =>
key === "content-type" ? "application/json" : null,
Expand Down Expand Up @@ -2602,6 +2657,7 @@ describe("API query builders", () => {

globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: {
get: (key: string) =>
key === "content-type" ? "application/json" : null,
Expand Down Expand Up @@ -2640,6 +2696,7 @@ describe("API query builders", () => {

globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: {
get: (key: string) =>
key === "content-type" ? "application/json" : null,
Expand Down Expand Up @@ -2678,6 +2735,7 @@ describe("API query builders", () => {

return Promise.resolve({
ok: true,
status: 200,
headers: {
get: (key: string) =>
key === "content-type" ? "application/json" : null,
Expand Down Expand Up @@ -2773,6 +2831,7 @@ describe("API query builders", () => {

return Promise.resolve({
ok: true,
status: 200,
headers: {
get: (key: string) =>
key === "content-type" ? "application/json" : null,
Expand Down Expand Up @@ -3200,6 +3259,7 @@ describe("API query builders", () => {
it("should call the repos endpoint without query", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: {
get: (key: string) =>
key === "content-type" ? "application/json" : null,
Expand Down Expand Up @@ -3236,6 +3296,7 @@ describe("API query builders", () => {
.fn()
.mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers({
"content-type": "application/json",
link: '<https://sentry.io/api/0/organizations/test-org/repos/?cursor=cursor-2>; rel="next"; results="true"; cursor="cursor-2"',
Expand All @@ -3252,6 +3313,7 @@ describe("API query builders", () => {
})
.mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers({
"content-type": "application/json",
}),
Expand Down Expand Up @@ -3283,6 +3345,7 @@ describe("API query builders", () => {
it("should include query parameter when provided", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: {
get: (key: string) =>
key === "content-type" ? "application/json" : null,
Expand Down Expand Up @@ -3312,6 +3375,7 @@ describe("API query builders", () => {
it("should return empty array when no repos exist", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: {
get: (key: string) =>
key === "content-type" ? "application/json" : null,
Expand All @@ -3329,6 +3393,7 @@ describe("API query builders", () => {
it("should accept repos with null externalSlug and externalId", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: {
get: (key: string) =>
key === "content-type" ? "application/json" : null,
Expand Down Expand Up @@ -3373,6 +3438,7 @@ describe("API query builders", () => {
it("should POST to the code mappings bulk endpoint", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: {
get: (key: string) =>
key === "content-type" ? "application/json" : null,
Expand Down Expand Up @@ -3423,6 +3489,7 @@ describe("API query builders", () => {
it("should handle updated mappings response", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: {
get: (key: string) =>
key === "content-type" ? "application/json" : null,
Expand Down Expand Up @@ -3481,6 +3548,7 @@ describe("API query builders", () => {

return Promise.resolve({
ok: true,
status: 200,
headers: {
get: (key: string) => {
if (key === "content-type") {
Expand Down Expand Up @@ -3526,6 +3594,7 @@ describe("API query builders", () => {
requestUrl = url;
return Promise.resolve({
ok: true,
status: 200,
headers: {
get: (key: string) =>
key === "content-type" ? "application/json" : null,
Expand All @@ -3552,6 +3621,7 @@ describe("API query builders", () => {
requestUrl = url;
return Promise.resolve({
ok: true,
status: 200,
headers: {
get: (key: string) =>
key === "content-type" ? "application/json" : null,
Expand All @@ -3578,6 +3648,7 @@ describe("API query builders", () => {
requestUrl = url;
return Promise.resolve({
ok: true,
status: 200,
headers: {
get: (key: string) =>
key === "content-type" ? "application/json" : null,
Expand Down
Loading
Loading