From 5710b8e8d4963819646f0a8aa3b9d75011b8cded Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:23:13 +0000 Subject: [PATCH 1/2] feat(toolkit-core): Add the shared API request core createApiClientCore sends a request through the host's transport with the host's headers, turns network failures into readable errors (ConfigurationError for an unreachable host), maps error responses to the status's ApiError through readErrorResponse, and parses JSON bodies. The transport, headers, and logger are injected, so it stays browser-portable. It is the MCP's request path, moved as is; the next commit switches SentryApiService onto it. --- packages/toolkit-core/README.md | 8 + .../toolkit-core/src/api-client/core.test.ts | 250 ++++++++++++++++++ packages/toolkit-core/src/api-client/core.ts | 223 ++++++++++++++++ 3 files changed, 481 insertions(+) create mode 100644 packages/toolkit-core/src/api-client/core.test.ts create mode 100644 packages/toolkit-core/src/api-client/core.ts diff --git a/packages/toolkit-core/README.md b/packages/toolkit-core/README.md index 840a605f5..ba125a3e5 100644 --- a/packages/toolkit-core/README.md +++ b/packages/toolkit-core/README.md @@ -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 `/api/0`, 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. diff --git a/packages/toolkit-core/src/api-client/core.test.ts b/packages/toolkit-core/src/api-client/core.test.ts new file mode 100644 index 000000000..fb097ecc5 --- /dev/null +++ b/packages/toolkit-core/src/api-client/core.test.ts @@ -0,0 +1,250 @@ +import { describe, expect, it, vi } from "vitest"; +import { type ApiRequestTransport, createApiClientCore } from "./core.js"; +import { + ApiNotFoundError, + ApiRateLimitError, + ApiServerError, + ConfigurationError, +} from "./errors.js"; + +const ORIGIN = "https://us.sentry.io"; + +function jsonResponse( + body: unknown, + init: { status?: number; headers?: Record } = {}, +): Response { + return new Response(JSON.stringify(body), { + status: init.status, + headers: { "content-type": "application/json", ...init.headers }, + }); +} + +/** A core over a transport that answers every request with `respond`. */ +function setup(respond: ApiRequestTransport["request"]) { + const transport = { request: vi.fn(respond) }; + const logger = { warn: vi.fn() }; + const core = createApiClientCore({ + transport, + headers: () => ({ Authorization: "Bearer token", "User-Agent": "test" }), + logger, + }); + return { core, transport, logger }; +} + +describe("createApiClientCore request", () => { + it("sends the host's headers to the API URL, replacing the caller's", async () => { + const { core, transport } = setup(async () => jsonResponse({})); + const signal = new AbortController().signal; + + await core.request(ORIGIN, "/organizations/acme/", { + method: "PUT", + body: "{}", + signal, + headers: { "X-Dropped": "1" }, + }); + + expect(transport.request).toHaveBeenCalledWith( + "https://us.sentry.io/api/0/organizations/acme/", + { + method: "PUT", + body: "{}", + signal, + headers: { + "Content-Type": "application/json", + Authorization: "Bearer token", + "User-Agent": "test", + }, + }, + ); + }); + + it("throws the host's header error before sending", async () => { + const transport = { request: vi.fn() }; + const core = createApiClientCore({ + transport, + headers: () => { + throw new ConfigurationError("Malformed authentication token"); + }, + logger: { warn: vi.fn() }, + }); + + await expect(core.request(ORIGIN, "/")).rejects.toThrow( + "Malformed authentication token", + ); + expect(transport.request).not.toHaveBeenCalled(); + }); + + it("throws the status's ApiError with the server's detail", async () => { + const { core, logger } = setup(async () => + jsonResponse({ detail: "Project not found" }, { status: 404 }), + ); + + const error = await core + .request(ORIGIN, "/projects/acme/x/") + .catch((e: unknown) => e); + + expect(error).toBeInstanceOf(ApiNotFoundError); + expect(error).toMatchObject({ + message: "Project not found", + status: 404, + detail: "Project not found", + responseBody: { detail: "Project not found" }, + }); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it("returns an allowed error status instead of throwing", async () => { + const { core } = setup(async () => + jsonResponse({ detail: "Gone" }, { status: 404 }), + ); + + const response = await core.request(ORIGIN, "/x/", undefined, { + allowStatuses: [404], + }); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ detail: "Gone" }); + }); + + it("carries a rate limit's wait on the error", async () => { + const { core } = setup(async () => + jsonResponse( + { detail: "Rate limit exceeded" }, + { status: 429, headers: { "Retry-After": "3" } }, + ), + ); + + const error = await core.request(ORIGIN, "/x/").catch((e: unknown) => e); + + expect(error).toBeInstanceOf(ApiRateLimitError); + expect(error).toMatchObject({ retryAfterMs: 3000 }); + }); + + it("reports an HTML error page as a server error and logs it", async () => { + const { core, logger } = setup( + async () => + new Response("Bad gateway", { + status: 502, + statusText: "Bad Gateway", + }), + ); + + const error = await core.request(ORIGIN, "/x/").catch((e: unknown) => e); + + expect(error).toBeInstanceOf(ApiServerError); + expect((error as Error).message).toBe( + "Server error: Received HTML instead of JSON (502 Bad Gateway). This may indicate an invalid URL or server issue.", + ); + expect(logger.warn).toHaveBeenCalledWith( + "Received HTML error page instead of JSON", + expect.objectContaining({ + status: 502, + host: "us.sentry.io", + path: "/x/", + }), + ); + }); + + it("falls back to the status text and body for an unreadable error", async () => { + const { core, logger } = setup( + async () => + new Response("upstream timed out", { + status: 503, + statusText: "Service Unavailable", + }), + ); + + const error = await core.request(ORIGIN, "/x/").catch((e: unknown) => e); + + expect(error).toBeInstanceOf(ApiServerError); + expect((error as Error).message).toBe( + "API request failed: Service Unavailable\nupstream timed out", + ); + expect(logger.warn).toHaveBeenCalledWith( + "Failed to parse JSON error response", + expect.objectContaining({ bodyPreview: "upstream timed out" }), + ); + }); + + it("logs a JSON error body with no string detail", async () => { + const { core, logger } = setup(async () => + jsonResponse({ detail: { code: "x" } }, { status: 400 }), + ); + + await expect(core.request(ORIGIN, "/x/")).rejects.toThrow( + "API request failed", + ); + expect(logger.warn).toHaveBeenCalledWith( + "Failed to parse validated API error response", + expect.objectContaining({ + validationErrorMessage: "Error response has no string detail", + }), + ); + }); + + it("reports an unreachable host as a configuration error", async () => { + const { core } = setup(async () => { + throw new TypeError("fetch failed", { + cause: new Error("getaddrinfo ENOTFOUND us.sentry.io"), + }); + }); + + const error = await core.request(ORIGIN, "/x/").catch((e: unknown) => e); + + expect(error).toBeInstanceOf(ConfigurationError); + expect((error as Error).message).toBe( + "Unable to connect to https://us.sentry.io/api/0/x/ - Hostname not found. Verify the URL is correct.", + ); + }); + + it("names the URL for other network failures", async () => { + const { core } = setup(async () => { + throw new TypeError("fetch failed", { + cause: new Error("read ECONNRESET"), + }); + }); + + const error = await core.request(ORIGIN, "/x/").catch((e: unknown) => e); + + expect(error).not.toBeInstanceOf(ConfigurationError); + expect((error as Error).message).toBe( + "Unable to connect to https://us.sentry.io/api/0/x/ - Connection reset. Try again in a moment.", + ); + }); + + it("rethrows the caller's abort unchanged", async () => { + const controller = new AbortController(); + const reason = new DOMException("stopped", "AbortError"); + const { core } = setup(async () => { + controller.abort(reason); + throw reason; + }); + + await expect( + core.request(ORIGIN, "/x/", { signal: controller.signal }), + ).rejects.toBe(reason); + }); +}); + +describe("createApiClientCore requestJson", () => { + it("returns the parsed body", async () => { + const { core } = setup(async () => jsonResponse([{ slug: "acme" }])); + + expect(await core.requestJson(ORIGIN, "/organizations/")).toEqual([ + { slug: "acme" }, + ]); + }); + + it("rejects a successful response that is not JSON", async () => { + const { core } = setup( + async () => + new Response("", { + headers: { "content-type": "text/html" }, + }), + ); + + await expect(core.requestJson(ORIGIN, "/organizations/")).rejects.toThrow( + "Expected JSON response but received HTML", + ); + }); +}); diff --git a/packages/toolkit-core/src/api-client/core.ts b/packages/toolkit-core/src/api-client/core.ts new file mode 100644 index 000000000..e55399d6e --- /dev/null +++ b/packages/toolkit-core/src/api-client/core.ts @@ -0,0 +1,223 @@ +/** + * The request core that endpoint wrappers build on. It sends a request + * through the host's transport, turns network failures and error responses + * into the shared errors, and reads JSON bodies. + * + * The host supplies the transport (retries, caching), the per-request + * headers (credentials, user agent, attribution), and logging. + */ + +import { buildSentryApiUrl } from "../api-request.js"; +import { type ApiError, ConfigurationError, createApiError } from "./errors.js"; +import { + type ErrorResponseBody, + readErrorResponse, + readJsonResponse, +} from "./response.js"; + +/** What the core needs from the host's transport. */ +export type ApiRequestTransport = { + request(url: string, init: RequestInit): Promise; +}; + +export type ApiClientLogger = { + warn(message: string, extra: Record): void; +}; + +export type ApiClientCoreOptions = { + transport: ApiRequestTransport; + /** + * Headers for every request after `Content-Type`, such as Authorization + * and User-Agent. Called once per request; may throw for a bad credential. + * They replace any headers the caller passes. + */ + headers: () => Record; + logger: ApiClientLogger; +}; + +export type ApiRequestOptions = { + /** Error statuses returned as the response instead of thrown. */ + allowStatuses?: number[]; +}; + +export type ApiClientCore = { + /** + * Send a request to `origin` + `/api/0` + `path`. + * + * @throws {ApiError} For an error status not in `allowStatuses` + * @throws {ConfigurationError} When the host is unreachable or misconfigured + * @throws {Error} For other network failures, with a readable message + */ + request( + origin: string, + path: string, + init?: RequestInit, + options?: ApiRequestOptions, + ): Promise; + /** {@link ApiClientCore.request}, then the parsed JSON body. */ + requestJson( + origin: string, + path: string, + init?: RequestInit, + ): Promise; +}; + +/** Readable messages for common network error codes. */ +const NETWORK_ERROR_MESSAGES: Record = { + 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.", +}; + +/** Network failures that mean the host is wrong or unreachable. */ +const CONFIGURATION_ERROR_CODES = new Set([ + "ENOTFOUND", + "EAI_AGAIN", + "ECONNREFUSED", + "ETIMEDOUT", +]); + +/** A failed fetch as a readable error naming the URL. */ +function networkError(url: string, error: unknown): Error { + let rootCause = error; + while (rootCause instanceof Error && rootCause.cause) { + rootCause = rootCause.cause; + } + const errorMessage = + rootCause instanceof Error ? rootCause.message : String(rootCause); + const errorCode = Object.keys(NETWORK_ERROR_MESSAGES).find((code) => + errorMessage.includes(code), + ); + const friendlyMessage = `Unable to connect to ${url} - ${ + errorCode ? NETWORK_ERROR_MESSAGES[errorCode] : errorMessage + }`; + if ( + (errorCode && CONFIGURATION_ERROR_CODES.has(errorCode)) || + errorMessage.includes("Connect Timeout Error") + ) { + return new ConfigurationError(friendlyMessage, { cause: error }); + } + return new Error(friendlyMessage, { cause: error }); +} + +/** A failed response's body as the matching `ApiError` subclass. */ +function errorFromResponse( + logger: ApiClientLogger, + response: Response, + body: ErrorResponseBody, + logExtra: Record, +): ApiError { + const { retryAfterMs } = body; + const errorText = body.text ?? ""; + const bodyPreview = + errorText.length > 256 ? `${errorText.slice(0, 253)}…` : errorText; + + if (body.parseError !== undefined) { + if (body.isHtml) { + logger.warn("Received HTML error page instead of JSON", { + ...logExtra, + parseErrorMessage: body.parseError, + }); + // An HTML page instead of JSON usually means a wrong URL or a proxy. + 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, + ); + } + logger.warn("Failed to parse JSON error response", { + ...logExtra, + bodyPreview, + parseErrorMessage: body.parseError, + }); + } else if (body.json) { + if (body.detail !== undefined) { + return createApiError( + body.detail, + response.status, + body.detail, + body.json, + retryAfterMs, + ); + } + logger.warn("Failed to parse validated API error response", { + ...logExtra, + bodyPreview, + validationErrorMessage: "Error response has no string detail", + }); + } + + return createApiError( + `API request failed: ${response.statusText}\n${errorText}`, + response.status, + errorText, + undefined, + retryAfterMs, + ); +} + +export function createApiClientCore({ + transport, + headers, + logger, +}: ApiClientCoreOptions): ApiClientCore { + async function request( + origin: string, + path: string, + init: RequestInit = {}, + { allowStatuses }: ApiRequestOptions = {}, + ): Promise { + const url = buildSentryApiUrl(origin, path); + const requestHeaders = { + "Content-Type": "application/json", + ...headers(), + }; + + 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.", + ); + } + + let response: Response; + try { + response = await transport.request(url, { + ...init, + headers: requestHeaders, + }); + } catch (error) { + // An abort, including one during a retry wait, keeps the caller's reason. + if (init.signal?.aborted) { + throw error; + } + throw networkError(url, error); + } + + if (response.ok || allowStatuses?.includes(response.status)) { + return response; + } + throw errorFromResponse( + logger, + response, + await readErrorResponse(response), + { + status: response.status, + statusText: response.statusText, + host: new URL(origin).host, + path, + }, + ); + } + + return { + request, + + async requestJson(origin, path, init) { + return await readJsonResponse(await request(origin, path, init)); + }, + }; +} From fc6aa087567062eb400a4afa19bd7b21822e23de Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:23:13 +0000 Subject: [PATCH 2/2] ref(mcp): Send SentryApiService requests through the shared core request and requestJSON keep their signatures and delegate to createApiClientCore, so no endpoint method changes. SentryApiService supplies its headers (User-Agent, Authorization, MCP attribution), the shared transport, and logWarn under the api.client scope. Its copies of the network error messages and the error-response mapping are deleted. The one difference: the error-response warnings now log the host the request went to, rather than the service's default host, when a call overrides the host. --- docs/contributing/api-patterns.md | 10 +- packages/mcp-core/src/api-client/client.ts | 239 +++++---------------- 2 files changed, 54 insertions(+), 195 deletions(-) diff --git a/docs/contributing/api-patterns.md b/docs/contributing/api-patterns.md index c35b6d52c..8c84e662a 100644 --- a/docs/contributing/api-patterns.md +++ b/docs/contributing/api-patterns.md @@ -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 diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index ee799106a..ffda2320f 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -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"; @@ -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) @@ -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 = { - 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; @@ -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. @@ -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 { + const headers: Record = { "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 @@ -880,111 +831,14 @@ export class SentryApiService { options: RequestInit = {}, { host, allowStatuses }: { host?: string; allowStatuses?: number[] } = {}, ): Promise { - const url = buildSentryApiUrl( - `${this.protocol}://${host ?? this.host}`, - path, - ); - - const headers: Record = { - "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 @@ -998,13 +852,16 @@ export class SentryApiService { options: RequestInit = {}, requestOptions?: { host?: string }, ): Promise { - 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) => {