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

`ApiError` and its subclasses live in the shared client,
`@sentry/toolkit-core/api-client/errors`, and `SentryApiService` builds them
from `readErrorResponse` in `@sentry/toolkit-core/api-client/response`. The
CLI reads raw error responses with the same helper and maps them to its own
exit-coded `ApiError`. See
`@sentry/toolkit-core/api-client/errors`. `SentryApiService` sends requests
through the shared request core, `@sentry/toolkit-core/api-client/core`,
which builds them from `readErrorResponse` in
`@sentry/toolkit-core/api-client/response`. The CLI reads raw error
responses with the same helper and maps them to its own exit-coded
`ApiError`. See
[`packages/toolkit-core/README.md`](../../packages/toolkit-core/README.md#api-client).

### Automatic Retries
Expand Down
239 changes: 48 additions & 191 deletions packages/mcp-core/src/api-client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ import {
listProjectEventAttachments,
parseSentryLinkHeader,
} from "@sentry/api";
import { buildSentryApiUrl } from "@sentry/toolkit-core/api-request";
import { sentryBearerHeader } from "@sentry/toolkit-core/auth-token";
import type { RetryPolicy } from "@sentry/toolkit-core/api-client/retry";
import { createApiClientCore } from "@sentry/toolkit-core/api-client/core";
import { createApiTransport } from "@sentry/toolkit-core/api-client/transport";
import { z } from "zod";
import { DEFAULT_SEARCH_ISSUES_PERIOD } from "../constants";
Expand Down Expand Up @@ -211,11 +211,7 @@ import type {
User,
UserReportList,
} from "./types";
import {
type ErrorResponseBody,
readErrorResponse,
readJsonResponse,
} from "@sentry/toolkit-core/api-client/response";
import { readJsonResponse } from "@sentry/toolkit-core/api-client/response";

// TODO: this is shared - so ideally, for safety, it uses @sentry/core, but currently
// logger isnt exposed (or rather, it is, but its not the right logger)
Expand Down Expand Up @@ -249,18 +245,6 @@ type ExplorerUrlParams = ExplorerAggregateParams & {
end?: string;
};

/**
* Mapping of common network error codes to user-friendly messages.
* These help users understand and resolve connection issues.
*/
const NETWORK_ERROR_MESSAGES: Record<string, string> = {
EAI_AGAIN: "DNS temporarily unavailable. Check your internet connection.",
ENOTFOUND: "Hostname not found. Verify the URL is correct.",
ECONNREFUSED: "Connection refused. Ensure the service is accessible.",
ETIMEDOUT: "Connection timed out. Check network connectivity.",
ECONNRESET: "Connection reset. Try again in a moment.",
};

function normalizeStatsPeriod(statsPeriod?: string): string | undefined {
const normalized = statsPeriod?.trim();
return normalized || undefined;
Expand Down Expand Up @@ -622,6 +606,14 @@ export class SentryApiService {
protected host: string;
protected protocol: SentryProtocol;
protected apiPrefix: string;
private readonly core = createApiClientCore({
transport,
headers: () => this.requestHeaders(),
logger: {
warn: (message, extra) =>
logWarn(message, { loggerScope: ["api", "client"], extra }),
},
});

/**
* Creates a new Sentry API service instance.
Expand Down Expand Up @@ -786,80 +778,39 @@ export class SentryApiService {
}
}

/** Map a failed response's body to the matching `ApiError` subclass. */
private errorFromResponse(
response: Response,
body: ErrorResponseBody,
path: string,
): ApiError {
const { retryAfterMs } = body;
const errorText = body.text ?? "";
const logExtra = {
status: response.status,
statusText: response.statusText,
host: this.host,
path,
};
const bodyPreview =
errorText.length > 256 ? `${errorText.slice(0, 253)}…` : errorText;

if (body.parseError !== undefined) {
if (body.isHtml) {
logWarn("Received HTML error page instead of JSON", {
loggerScope: ["api", "client"],
extra: { ...logExtra, parseErrorMessage: body.parseError },
});
// HTML response instead of JSON typically indicates a server configuration issue
return createApiError(
`Server error: Received HTML instead of JSON (${response.status} ${response.statusText}). This may indicate an invalid URL or server issue.`,
response.status,
errorText,
undefined,
retryAfterMs,
);
}
logWarn("Failed to parse JSON error response", {
loggerScope: ["api", "client"],
extra: { ...logExtra, bodyPreview, parseErrorMessage: body.parseError },
});
} else if (body.json) {
if (body.detail !== undefined) {
return createApiError(
body.detail,
response.status,
body.detail,
body.json,
retryAfterMs,
);
/** Headers for every request after `Content-Type`. */
private requestHeaders(): Record<string, string> {
const headers: Record<string, string> = { "User-Agent": USER_AGENT };
if (this.accessToken !== null) {
const authorization = sentryBearerHeader(this.accessToken);
if (authorization === null) {
throw new ConfigurationError("Malformed authentication token");
}
logWarn("Failed to parse validated API error response", {
loggerScope: ["api", "client"],
extra: {
...logExtra,
bodyPreview,
validationErrorMessage: "Error response has no string detail",
},
});
headers.Authorization = authorization;
}
if (this.clientId) {
headers["X-Sentry-MCP-Client-Id"] = this.clientId;
}
if (this.clientName) {
headers["X-Sentry-MCP-Client-Name"] = this.clientName;
}
if (this.clientFamily) {
headers["X-Sentry-MCP-Client-Family"] = this.clientFamily;
}
if (this.utmSource) {
headers["X-Sentry-MCP-Utm-Source"] = this.utmSource;
}
return headers;
}

// Use the error factory to create the appropriate error type based on status
return createApiError(
`API request failed: ${response.statusText}\n${errorText}`,
response.status,
errorText,
undefined,
retryAfterMs,
);
private origin(host: string = this.host): string {
return `${this.protocol}://${host}`;
}

/**
* Makes an authenticated request to the Sentry API through the shared transport.
*
* Handles:
* - Bearer token authentication
* - Error response parsing and enhancement
* - Multi-region host overrides
* - Fetch availability validation
* Makes an authenticated request through the shared client core
* (`@sentry/toolkit-core/api-client/core`), which maps network failures
* and error responses to `ApiError` subclasses.
*
* Retries happen in the transport, never here, so they do not stack: a
* rate-limited (429) request, or a GET that hits a 500/502/503/504 or a
Expand All @@ -880,111 +831,14 @@ export class SentryApiService {
options: RequestInit = {},
{ host, allowStatuses }: { host?: string; allowStatuses?: number[] } = {},
): Promise<Response> {
const url = buildSentryApiUrl(
`${this.protocol}://${host ?? this.host}`,
path,
);

const headers: Record<string, string> = {
"Content-Type": "application/json",
"User-Agent": USER_AGENT,
};
if (this.accessToken !== null) {
const authorization = sentryBearerHeader(this.accessToken);
if (authorization === null) {
throw new ConfigurationError("Malformed authentication token");
}
headers.Authorization = authorization;
}
if (this.clientId) {
headers["X-Sentry-MCP-Client-Id"] = this.clientId;
}
if (this.clientName) {
headers["X-Sentry-MCP-Client-Name"] = this.clientName;
}
if (this.clientFamily) {
headers["X-Sentry-MCP-Client-Family"] = this.clientFamily;
}
if (this.utmSource) {
headers["X-Sentry-MCP-Utm-Source"] = this.utmSource;
}

// Check if fetch is available, otherwise provide a helpful error message
if (typeof globalThis.fetch === "undefined") {
throw new ConfigurationError(
"fetch is not available. Please use Node.js >= 18 or ensure fetch is available in your environment.",
);
}

// logger.info(logger.fmt`[sentryApi] ${options.method || "GET"} ${url}`);
let response: Response;
try {
response = await transport.request(url, {
...options,
headers,
});
} catch (error) {
// An abort, including one during a retry wait, keeps the caller's reason.
if (options.signal?.aborted) {
throw error;
}
// Extract the root cause from the error chain
let rootCause = error;
while (rootCause instanceof Error && rootCause.cause) {
rootCause = rootCause.cause;
}

const errorMessage =
rootCause instanceof Error ? rootCause.message : String(rootCause);

let friendlyMessage = `Unable to connect to ${url}`;

// Check if we have a specific message for this error
const errorCode = Object.keys(NETWORK_ERROR_MESSAGES).find((code) =>
errorMessage.includes(code),
);

if (errorCode) {
friendlyMessage += ` - ${NETWORK_ERROR_MESSAGES[errorCode]}`;
} else {
friendlyMessage += ` - ${errorMessage}`;
}

// DNS resolution failures and connection timeouts to custom hosts are configuration issues
if (
errorCode === "ENOTFOUND" ||
errorCode === "EAI_AGAIN" ||
errorCode === "ECONNREFUSED" ||
errorCode === "ETIMEDOUT" ||
errorMessage.includes("Connect Timeout Error")
) {
throw new ConfigurationError(friendlyMessage, { cause: error });
}

throw new Error(friendlyMessage, { cause: error });
}

// Handle error responses generically
if (!response.ok) {
if (allowStatuses?.includes(response.status)) {
return response;
}
throw this.errorFromResponse(
response,
await readErrorResponse(response),
path,
);
}

return response;
return await this.core.request(this.origin(host), path, options, {
allowStatuses,
});
}

/**
* Makes a request to the Sentry API and parses the JSON response.
*
* This is the primary method for API calls that expect JSON responses.
* It automatically validates Content-Type and provides helpful error messages
* for common issues like authentication failures or server errors.
* Makes a request to the Sentry API and parses the JSON response,
* checking its Content-Type.
*
* @param path API endpoint path (without /api/0 prefix)
* @param options Fetch options
Expand All @@ -998,13 +852,16 @@ export class SentryApiService {
options: RequestInit = {},
requestOptions?: { host?: string },
): Promise<unknown> {
const response = await this.request(path, options, requestOptions);
return await readJsonResponse(response);
return await this.core.requestJson(
this.origin(requestOptions?.host),
path,
options,
);
}

/** Let SDK operations build paths and queries while MCP owns transport and errors. */
private sdkClient(expectedPath: string, host: string = this.host) {
const origin = new URL(`${this.protocol}://${host}`).origin;
const origin = new URL(this.origin(host)).origin;
return createSentryClient({
baseUrl: origin,
fetch: async (sdkRequest) => {
Expand Down
8 changes: 8 additions & 0 deletions packages/toolkit-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ the one exception.
rate-limit wait, and each host maps that to its own errors.
`readJsonResponse` parses a successful body after checking its
Content-Type.
- `core` is the request core endpoint wrappers build on.
`createApiClientCore` takes the host's transport, per-request headers
(credentials, user agent, attribution), and logger. Its `request` sends
to `<origin>/api/0<path>`, turns network failures into readable errors
(`ConfigurationError` for an unreachable host), and throws the status's
`ApiError` for an error response unless the caller allows that status.
`requestJson` also parses the body. The MCP's `SentryApiService` sends
every request through it.
- `pagination`, `retry`, `transport`, and `response-cache` are described in
the sections below. The `node:zlib` body codec stays outside, at
`response-cache/codec`, since browsers lack it.
Expand Down
Loading
Loading