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) => { 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)); + }, + }; +}