From 89a19d80c3736f8a28852d0e7c0a4318d303d0b2 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:49:54 +0000 Subject: [PATCH 1/4] feat(cli): Retry writes Sentry marks idempotent Read idempotency from the @sentry/api operation catalog instead of hard-coding it at each call site. Sentry now publishes chunk-upload and the DIF and artifact bundle assemble endpoints with x-sentry-idempotent, and @sentry/api 0.420.0 exposes it as `idempotent` on each operation. generate:schema writes the marked non-safe operations to src/generated/idempotent-operations.json, and the authenticated fetch retries any request that matches one. The per-call `idempotent: true` opt-ins for those endpoints are gone. Preprod assemble keeps its opt-in because Sentry does not mark it. Bumps @sentry/api from 0.400.0 to 0.420.0 for the CLI and MCP. Refs getsentry/sentry#128927 --- packages/cli/script/ensure-generated.mjs | 8 +- packages/cli/script/generate-api-schema.ts | 27 +++++++ packages/cli/src/global.d.ts | 5 ++ packages/cli/src/lib/api/chunk-upload.ts | 10 +-- packages/cli/src/lib/api/dart-symbols.ts | 3 - packages/cli/src/lib/api/debug-files.ts | 2 - packages/cli/src/lib/api/infrastructure.ts | 5 +- packages/cli/src/lib/api/proguard.ts | 3 - packages/cli/src/lib/api/sourcemaps.ts | 2 - packages/cli/src/lib/idempotent-operations.ts | 65 +++++++++++++++ packages/cli/src/lib/retry-policy.ts | 6 +- packages/cli/src/lib/sentry-client.ts | 14 +++- .../test/lib/idempotent-operations.test.ts | 81 +++++++++++++++++++ packages/cli/test/lib/sentry-client.test.ts | 39 +++++++++ pnpm-lock.yaml | 14 ++-- pnpm-workspace.yaml | 4 +- 16 files changed, 253 insertions(+), 35 deletions(-) create mode 100644 packages/cli/src/lib/idempotent-operations.ts create mode 100644 packages/cli/test/lib/idempotent-operations.test.ts diff --git a/packages/cli/script/ensure-generated.mjs b/packages/cli/script/ensure-generated.mjs index 2a7cd780f..79bdeb2e5 100644 --- a/packages/cli/script/ensure-generated.mjs +++ b/packages/cli/script/ensure-generated.mjs @@ -18,7 +18,11 @@ import { execSync } from "node:child_process"; import { existsSync } from "node:fs"; -const SCHEMA = "src/generated/api-schema.json"; +/** Produced by `generate:schema`; imported at runtime by `src/`. */ +const SCHEMA_OUTPUTS = [ + "src/generated/api-schema.json", + "src/generated/idempotent-operations.json", +]; /** Produced by `generate:grammar`; imported by `src/lib/arg-parsing.ts`. */ const GRAMMAR_OUTPUTS = [ "src/generated/org-project-target-grammar.js", @@ -35,7 +39,7 @@ function run(script) { execSync(`pnpm run ${script}`, { stdio: "inherit" }); } -if (!existsSync(SCHEMA)) { +if (!SCHEMA_OUTPUTS.every(existsSync)) { run("generate:schema"); } if (process.argv[2] !== "schema" && !GRAMMAR_OUTPUTS.every(existsSync)) { diff --git a/packages/cli/script/generate-api-schema.ts b/packages/cli/script/generate-api-schema.ts index 7c86e6644..cf128099d 100644 --- a/packages/cli/script/generate-api-schema.ts +++ b/packages/cli/script/generate-api-schema.ts @@ -12,16 +12,22 @@ * Also reads SDK function names from the installed @sentry/api package to * map operationIds to their TypeScript SDK function names. * + * Also reads the `@sentry/api/operations` catalog to list the non-safe + * operations Sentry marks idempotent (`x-sentry-idempotent`), which the + * authenticated fetch retries on transient failures. + * * Usage: * tsx script/generate-api-schema.ts * * Output: * src/generated/api-schema.json + * src/generated/idempotent-operations.json */ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { operations } from "@sentry/api/operations"; /** Resolve the root directory of @sentry/api by finding its main entry point. */ function sentryApiDir(): string { @@ -32,6 +38,7 @@ function sentryApiDir(): string { } const OUTPUT_PATH = "src/generated/api-schema.json"; +const IDEMPOTENT_OUTPUT_PATH = "src/generated/idempotent-operations.json"; /** * Build the OpenAPI spec URL from the installed @sentry/api version. @@ -53,6 +60,8 @@ const PATH_PARAM_PATTERN = /\{(\w+)\}/g; export type { ApiEndpoint } from "../src/lib/api-schema.js"; import type { ApiEndpoint } from "../src/lib/api-schema.js"; +import type { IdempotentOperation } from "../src/lib/idempotent-operations.js"; +import { isRetryableMethod } from "../src/lib/retry-policy.js"; // --------------------------------------------------------------------------- // OpenAPI Types (minimal subset we need) @@ -231,3 +240,21 @@ await writeFile(OUTPUT_PATH, JSON.stringify(endpoints, null, 2)); console.log( `Generated ${OUTPUT_PATH} (${endpoints.length} endpoints, ${Math.round(JSON.stringify(endpoints).length / 1024)}KB)`, ); + +// Safe methods already retry, so only writes need listing; that keeps the +// file the CLI loads on every request tiny. +const idempotentOperations: IdempotentOperation[] = operations + .filter((op) => op.idempotent && !isRetryableMethod(op.method)) + .map((op) => ({ method: op.method, path: op.path })) + .sort( + (a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method), + ); + +await writeFile( + IDEMPOTENT_OUTPUT_PATH, + JSON.stringify(idempotentOperations, null, 2), +); + +console.log( + `Generated ${IDEMPOTENT_OUTPUT_PATH} (${idempotentOperations.length} operations)`, +); diff --git a/packages/cli/src/global.d.ts b/packages/cli/src/global.d.ts index 0d36faf7e..1433484f6 100644 --- a/packages/cli/src/global.d.ts +++ b/packages/cli/src/global.d.ts @@ -47,3 +47,8 @@ declare module "*/generated/api-schema.json" { const endpoints: unknown; export default endpoints; } + +declare module "*/generated/idempotent-operations.json" { + const operations: unknown; + export default operations; +} diff --git a/packages/cli/src/lib/api/chunk-upload.ts b/packages/cli/src/lib/api/chunk-upload.ts index a96ecfc56..4846d6f61 100644 --- a/packages/cli/src/lib/api/chunk-upload.ts +++ b/packages/cli/src/lib/api/chunk-upload.ts @@ -395,8 +395,8 @@ export async function uploadMissingChunks(params: { } const limit = pLimit(serverOptions.concurrency); - // Chunks are stored by SHA-1, so a replayed upload is a no-op. - const { fetch: authFetch } = getSdkConfig(regionUrl, { idempotent: true }); + // Sentry marks chunk-upload idempotent, so the fetch retries it. + const { fetch: authFetch } = getSdkConfig(regionUrl); await Promise.all( missingChunks.map((chunk) => @@ -488,8 +488,8 @@ export async function uploadMissingBufferChunks(params: { } const limit = pLimit(serverOptions.concurrency); - // Chunks are stored by SHA-1, so a replayed upload is a no-op. - const { fetch: authFetch } = getSdkConfig(regionUrl, { idempotent: true }); + // Sentry marks chunk-upload idempotent, so the fetch retries it. + const { fetch: authFetch } = getSdkConfig(regionUrl); await Promise.all( missingChunks.map((chunk) => @@ -552,8 +552,6 @@ export async function pollAssembly(params: { method: "POST", body, schema, - // Assembly state is keyed by checksum; re-sending only polls it. - idempotent: true, }, ); diff --git a/packages/cli/src/lib/api/dart-symbols.ts b/packages/cli/src/lib/api/dart-symbols.ts index eb7858a80..6ca5212e8 100644 --- a/packages/cli/src/lib/api/dart-symbols.ts +++ b/packages/cli/src/lib/api/dart-symbols.ts @@ -159,8 +159,6 @@ export async function uploadDartSymbolMap( method: "POST", body: assembleBody, schema: DifAssembleResponseSchema, - // Assembly state is keyed by checksum; re-sending only polls it. - idempotent: true, }, ); @@ -196,7 +194,6 @@ export async function uploadDartSymbolMap( method: "POST", body: assembleBody, schema: DifAssembleResponseSchema, - idempotent: true, }, ); diff --git a/packages/cli/src/lib/api/debug-files.ts b/packages/cli/src/lib/api/debug-files.ts index b66494da5..a990b1f5b 100644 --- a/packages/cli/src/lib/api/debug-files.ts +++ b/packages/cli/src/lib/api/debug-files.ts @@ -272,7 +272,6 @@ async function postAssemble( endpoint: string, body: unknown, ): Promise { - // Assembly state is keyed by checksum; re-sending only polls it. const { data } = await apiRequestToRegion( regionUrl, endpoint, @@ -280,7 +279,6 @@ async function postAssemble( method: "POST", body, schema: DifAssembleResponseSchema, - idempotent: true, }, ); return data; diff --git a/packages/cli/src/lib/api/infrastructure.ts b/packages/cli/src/lib/api/infrastructure.ts index c3573193d..5688b58ea 100644 --- a/packages/cli/src/lib/api/infrastructure.ts +++ b/packages/cli/src/lib/api/infrastructure.ts @@ -212,8 +212,9 @@ export type ApiRequestOptions = { /** Manually validate each redirect before forwarding credentials. */ validatedRedirects?: boolean; /** - * Retry transient failures even for writes. Only for requests the server - * dedupes (see `idempotent` in sentry-client.ts). + * Retry transient failures even for writes the server does not mark + * idempotent. Only for requests the server dedupes (see `idempotent` in + * sentry-client.ts). */ idempotent?: boolean; }; diff --git a/packages/cli/src/lib/api/proguard.ts b/packages/cli/src/lib/api/proguard.ts index 5cb1a1d99..5f2147488 100644 --- a/packages/cli/src/lib/api/proguard.ts +++ b/packages/cli/src/lib/api/proguard.ts @@ -175,8 +175,6 @@ export async function uploadProguardMappings( method: "POST", body: assembleBody, schema: DifAssembleResponseSchema, - // Assembly state is keyed by checksum; re-sending only polls it. - idempotent: true, }, ); @@ -237,7 +235,6 @@ async function pollDifAssembly(params: { method: "POST", body, schema: DifAssembleResponseSchema, - idempotent: true, }, ); diff --git a/packages/cli/src/lib/api/sourcemaps.ts b/packages/cli/src/lib/api/sourcemaps.ts index 2421bbffb..67425a1ad 100644 --- a/packages/cli/src/lib/api/sourcemaps.ts +++ b/packages/cli/src/lib/api/sourcemaps.ts @@ -340,8 +340,6 @@ async function uploadArtifactBundle(opts: { method: "POST", body: assembleBody, schema: AssembleResponseSchema, - // Assembly state is keyed by checksum; re-sending only polls it. - idempotent: true, }, ); diff --git a/packages/cli/src/lib/idempotent-operations.ts b/packages/cli/src/lib/idempotent-operations.ts new file mode 100644 index 000000000..f29460cd9 --- /dev/null +++ b/packages/cli/src/lib/idempotent-operations.ts @@ -0,0 +1,65 @@ +/** + * Writes Sentry declares safe to replay. + * + * `script/generate-api-schema.ts` copies the non-safe operations that the + * `@sentry/api` catalog marks `idempotent` (from Sentry's + * `x-sentry-idempotent` OpenAPI extension). Endpoints opt in on the server, + * so the CLI no longer hard-codes which assemble and upload calls may retry. + */ + +import generated from "../generated/idempotent-operations.json"; + +/** A non-safe operation Sentry marks idempotent. */ +export type IdempotentOperation = { + /** Uppercase HTTP method */ + method: string; + /** OpenAPI path template, such as `/api/0/organizations/{org}/chunk-upload/` */ + path: string; +}; + +type Matcher = { method: string; pattern: RegExp }; + +const TEMPLATE_SEGMENT = /^\{[^/]+\}$/; +const REGEXP_SPECIAL = /[.*+?^${}()|[\]\\]/g; + +function toMatcher({ method, path }: IdempotentOperation): Matcher { + const source = path + .split("/") + .map((segment) => + TEMPLATE_SEGMENT.test(segment) + ? "[^/]+" + : segment.replace(REGEXP_SPECIAL, "\\$&"), + ) + .join("/"); + return { method: method.toUpperCase(), pattern: new RegExp(`^${source}$`) }; +} + +let matchers: Matcher[] | undefined; + +/** + * Whether Sentry marks this request's operation idempotent. + * + * Matches from the `/api/` segment on, so a self-hosted install served under + * a path prefix still matches. + * + * @param method - HTTP method (any case) + * @param url - Absolute request URL + */ +export function isIdempotentOperation(method: string, url: string): boolean { + if (!URL.canParse(url)) { + return false; + } + const pathname = new URL(url).pathname; + const apiIndex = pathname.indexOf("/api/"); + if (apiIndex === -1) { + return false; + } + const apiPath = pathname.slice(apiIndex); + const normalized = apiPath.endsWith("/") ? apiPath : `${apiPath}/`; + const upper = method.toUpperCase(); + + matchers ??= (generated as IdempotentOperation[]).map(toMatcher); + return matchers.some( + (matcher) => matcher.method === upper && matcher.pattern.test(normalized), + ); +} diff --git a/packages/cli/src/lib/retry-policy.ts b/packages/cli/src/lib/retry-policy.ts index aeebfbde9..bb9ec4c36 100644 --- a/packages/cli/src/lib/retry-policy.ts +++ b/packages/cli/src/lib/retry-policy.ts @@ -10,8 +10,10 @@ * server already applied it, so replaying could duplicate the write. * PUT and DELETE are excluded too: Sentry uses PUT for partial updates with * side effects (issue merges, activity, notifications), and a replayed - * DELETE turns a successful delete into a 404. Callers that know a write is - * safe to replay (content-addressed uploads) opt in per request. + * DELETE turns a successful delete into a 404. Writes Sentry marks + * idempotent (`x-sentry-idempotent`, see idempotent-operations.ts) replay, + * and callers that know an unmarked write is safe to replay opt in per + * request. * * A 429 is the exception and retries for any method: Sentry's rate-limit * middleware rejects the request before the view runs, so nothing was diff --git a/packages/cli/src/lib/sentry-client.ts b/packages/cli/src/lib/sentry-client.ts index e3543441a..8a8176497 100644 --- a/packages/cli/src/lib/sentry-client.ts +++ b/packages/cli/src/lib/sentry-client.ts @@ -45,6 +45,7 @@ import { TimeoutError, } from "./errors.js"; import { extractInputUrl } from "./fetch-input.js"; +import { isIdempotentOperation } from "./idempotent-operations.js"; import { logger } from "./logger.js"; import { type CacheLookup, @@ -707,7 +708,10 @@ async function fetchWithRetry({ } const attemptFactory = await buildAttemptFactory(input, init); const timeoutMs = resolveTimeoutMs(fullUrl); - const methodRetryable = isRetryableMethod(method, idempotent); + const methodRetryable = isRetryableMethod( + method, + idempotent || isIdempotentOperation(method, fullUrl), + ); const urlPath = extractUrlPath(fullUrl); for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { @@ -763,7 +767,8 @@ async function fetchWithRetry({ * - Auth token injection (Bearer token) * - Request timeout via AbortController * - Automatic retry on transient HTTP errors (408, 429, 5xx), network errors, - * and timeouts — for GET/HEAD/OPTIONS, or any method with `idempotent` + * and timeouts — for GET/HEAD/OPTIONS, operations Sentry marks idempotent + * (see idempotent-operations.ts), or any method with `idempotent` * - 429 handling (any method; rejected before processing): waits for the * server's rate-limit reset when it fits the cap, otherwise throws * {@link RateLimitError} @@ -784,8 +789,9 @@ type AuthenticatedFetchOptions = { bufferResponseBody?: boolean; /** * Caller asserts that replaying the request is harmless, so transient - * failures retry for any method. Only for writes the server dedupes, such - * as content-addressed chunk uploads and checksum-keyed assemble calls. + * failures retry for any method. Operations Sentry marks idempotent retry + * without this; set it only for writes the server dedupes but does not + * mark, such as preprod assemble polling. */ idempotent?: boolean; }; diff --git a/packages/cli/test/lib/idempotent-operations.test.ts b/packages/cli/test/lib/idempotent-operations.test.ts new file mode 100644 index 000000000..67f4c5eb5 --- /dev/null +++ b/packages/cli/test/lib/idempotent-operations.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from "vitest"; +import { isIdempotentOperation } from "../../src/lib/idempotent-operations.js"; + +const REGION_URL = "https://us.sentry.io"; + +describe("isIdempotentOperation", () => { + test.each([ + "/api/0/organizations/acme/chunk-upload/", + "/api/0/organizations/acme/artifactbundle/assemble/", + "/api/0/projects/acme/web/files/difs/assemble/", + ])("matches POST %s", (path) => { + expect(isIdempotentOperation("POST", `${REGION_URL}${path}`)).toBe(true); + }); + + test("ignores method case and a missing trailing slash", () => { + expect( + isIdempotentOperation( + "post", + `${REGION_URL}/api/0/organizations/acme/chunk-upload`, + ), + ).toBe(true); + }); + + test("matches under a self-hosted path prefix", () => { + expect( + isIdempotentOperation( + "POST", + "https://sentry.example.com/sentry/api/0/organizations/acme/chunk-upload/", + ), + ).toBe(true); + }); + + test("ignores the query string", () => { + expect( + isIdempotentOperation( + "POST", + `${REGION_URL}/api/0/organizations/acme/chunk-upload/?foo=bar`, + ), + ).toBe(true); + }); + + test("does not match other methods on a marked path", () => { + expect( + isIdempotentOperation( + "PUT", + `${REGION_URL}/api/0/organizations/acme/chunk-upload/`, + ), + ).toBe(false); + }); + + test("does not match writes Sentry leaves unmarked", () => { + expect( + isIdempotentOperation( + "POST", + `${REGION_URL}/api/0/projects/acme/web/files/preprodartifacts/assemble/`, + ), + ).toBe(false); + expect( + isIdempotentOperation( + "POST", + `${REGION_URL}/api/0/organizations/acme/issues/1/autofix/`, + ), + ).toBe(false); + }); + + test("does not let a template segment span several path segments", () => { + expect( + isIdempotentOperation( + "POST", + `${REGION_URL}/api/0/organizations/acme/extra/chunk-upload/`, + ), + ).toBe(false); + }); + + test("returns false for URLs it cannot parse or that are not API paths", () => { + expect(isIdempotentOperation("POST", "not a url")).toBe(false); + expect( + isIdempotentOperation("POST", `${REGION_URL}/organizations/acme/`), + ).toBe(false); + }); +}); diff --git a/packages/cli/test/lib/sentry-client.test.ts b/packages/cli/test/lib/sentry-client.test.ts index 12fe0e291..b86907027 100644 --- a/packages/cli/test/lib/sentry-client.test.ts +++ b/packages/cli/test/lib/sentry-client.test.ts @@ -244,6 +244,45 @@ describe("fetchWithRetry / buildAttemptFactory", () => { }); }); +describe("operations Sentry marks idempotent", () => { + test("retries a marked POST on a transient status without an opt-in", async () => { + let calls = 0; + globalThis.fetch = mockFetch(async () => { + calls += 1; + return calls === 1 + ? new Response("busy", { status: 503 }) + : jsonResponse({ state: "ok", missingChunks: [] }); + }); + + const res = await getAuthenticatedFetch()( + `${REGION_URL}/api/0/organizations/acme/artifactbundle/assemble/`, + { method: "POST", body: "{}" }, + ); + + expect(res.status).toBe(200); + expect(calls).toBe(2); + }); + + test("retries a marked POST after a network error without an opt-in", async () => { + let calls = 0; + globalThis.fetch = mockFetch(async () => { + calls += 1; + if (calls === 1) { + throw new TypeError("fetch failed"); + } + return jsonResponse({}); + }); + + const res = await getAuthenticatedFetch()( + `${REGION_URL}/api/0/projects/acme/web/files/difs/assemble/`, + { method: "POST", body: "{}" }, + ); + + expect(res.status).toBe(200); + expect(calls).toBe(2); + }); +}); + describe("non-idempotent requests", () => { test("does not retry a POST on a transient status", async () => { let calls = 0; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e0cc31f2b..32f73fd4d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,8 +40,8 @@ catalogs: specifier: ^1.2.3 version: 1.2.3 '@sentry/api': - specifier: 0.400.0 - version: 0.400.0 + specifier: 0.420.0 + version: 0.420.0 '@sentry/cloudflare': specifier: 11.6.0 version: 11.6.0 @@ -467,7 +467,7 @@ importers: version: 1.46.0(ai@6.0.230(zod@4.6.7))(express@5.3.0)(zod@4.6.7) '@sentry/api': specifier: 'catalog:' - version: 0.400.0(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7) + version: 0.420.0(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7) '@sentry/core': specifier: 'catalog:' version: 11.6.0 @@ -801,7 +801,7 @@ importers: version: 2.3.0 '@sentry/api': specifier: 'catalog:' - version: 0.400.0(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7) + version: 0.420.0(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7) '@sentry/core': specifier: 'catalog:' version: 11.6.0 @@ -4317,8 +4317,8 @@ packages: '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} - '@sentry/api@0.400.0': - resolution: {integrity: sha512-JEffW2hxsRTr3WuP0t/XW9lUdhZRR7RN+rOLQC7UwlqDzz7F893oIK4/8Ba7mNwtUeiYQ/4pl9rc7gG/iuItyQ==} + '@sentry/api@0.420.0': + resolution: {integrity: sha512-s5FOQbBy9qVdZGvZrBIfjJEInXPRW+mqWXFi/2qLqzqPY3tv0YD9tQDGozjShO03AOGV3WwHrUdIIcgFMOZ7vw==} engines: {node: '>=22'} peerDependencies: valibot: '*' @@ -12508,7 +12508,7 @@ snapshots: '@sec-ant/readable-stream@0.4.1': {} - '@sentry/api@0.400.0(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7)': + '@sentry/api@0.420.0(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7)': optionalDependencies: valibot: 1.4.2(typescript@5.9.3) zod: 4.6.7 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7f8f8dd0a..86bda1339 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -51,7 +51,7 @@ catalog: "@modelcontextprotocol/server": 2.3.0 "@radix-ui/react-accordion": ^1.2.20 "@radix-ui/react-slot": ^1.2.3 - "@sentry/api": 0.400.0 + "@sentry/api": 0.420.0 "@sentry/cloudflare": 11.6.0 "@sentry/core": 11.6.0 "@sentry/node": 11.6.0 @@ -163,6 +163,6 @@ minimumReleaseAgeExclude: - "@oxlint/binding-win32-x64-msvc@1.87.0" - oxfmt@0.72.0 - oxlint@1.87.0 - - "@sentry/api@0.400.0" + - "@sentry/api@0.420.0" - miniflare@5.20261019.1-beta - wrangler@4.151.3 From 4ce8373109a868a904c5ca300746a40316ff9566 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:56:37 +0000 Subject: [PATCH 2/4] fix(cli): Commit the idempotent operations list apps/local imports CLI modules that reach sentry-client.ts, and its CI job never runs generate:schema, so the gitignored generated JSON was missing there. Track the 3-entry file like banner-sixel.ts. The build still regenerates it from @sentry/api. --- packages/cli/script/ensure-generated.mjs | 8 ++------ packages/cli/script/generate-api-schema.ts | 2 +- .../cli/src/generated/idempotent-operations.json | 14 ++++++++++++++ packages/cli/src/global.d.ts | 5 ----- packages/cli/src/lib/idempotent-operations.ts | 4 +++- 5 files changed, 20 insertions(+), 13 deletions(-) create mode 100644 packages/cli/src/generated/idempotent-operations.json diff --git a/packages/cli/script/ensure-generated.mjs b/packages/cli/script/ensure-generated.mjs index 79bdeb2e5..2a7cd780f 100644 --- a/packages/cli/script/ensure-generated.mjs +++ b/packages/cli/script/ensure-generated.mjs @@ -18,11 +18,7 @@ import { execSync } from "node:child_process"; import { existsSync } from "node:fs"; -/** Produced by `generate:schema`; imported at runtime by `src/`. */ -const SCHEMA_OUTPUTS = [ - "src/generated/api-schema.json", - "src/generated/idempotent-operations.json", -]; +const SCHEMA = "src/generated/api-schema.json"; /** Produced by `generate:grammar`; imported by `src/lib/arg-parsing.ts`. */ const GRAMMAR_OUTPUTS = [ "src/generated/org-project-target-grammar.js", @@ -39,7 +35,7 @@ function run(script) { execSync(`pnpm run ${script}`, { stdio: "inherit" }); } -if (!SCHEMA_OUTPUTS.every(existsSync)) { +if (!existsSync(SCHEMA)) { run("generate:schema"); } if (process.argv[2] !== "schema" && !GRAMMAR_OUTPUTS.every(existsSync)) { diff --git a/packages/cli/script/generate-api-schema.ts b/packages/cli/script/generate-api-schema.ts index cf128099d..673ce193d 100644 --- a/packages/cli/script/generate-api-schema.ts +++ b/packages/cli/script/generate-api-schema.ts @@ -252,7 +252,7 @@ const idempotentOperations: IdempotentOperation[] = operations await writeFile( IDEMPOTENT_OUTPUT_PATH, - JSON.stringify(idempotentOperations, null, 2), + `${JSON.stringify(idempotentOperations, null, 2)}\n`, ); console.log( diff --git a/packages/cli/src/generated/idempotent-operations.json b/packages/cli/src/generated/idempotent-operations.json new file mode 100644 index 000000000..e2ce48199 --- /dev/null +++ b/packages/cli/src/generated/idempotent-operations.json @@ -0,0 +1,14 @@ +[ + { + "method": "POST", + "path": "/api/0/organizations/{organization_id_or_slug}/artifactbundle/assemble/" + }, + { + "method": "POST", + "path": "/api/0/organizations/{organization_id_or_slug}/chunk-upload/" + }, + { + "method": "POST", + "path": "/api/0/projects/{organization_id_or_slug}/{project_id_or_slug}/files/difs/assemble/" + } +] diff --git a/packages/cli/src/global.d.ts b/packages/cli/src/global.d.ts index 1433484f6..0d36faf7e 100644 --- a/packages/cli/src/global.d.ts +++ b/packages/cli/src/global.d.ts @@ -47,8 +47,3 @@ declare module "*/generated/api-schema.json" { const endpoints: unknown; export default endpoints; } - -declare module "*/generated/idempotent-operations.json" { - const operations: unknown; - export default operations; -} diff --git a/packages/cli/src/lib/idempotent-operations.ts b/packages/cli/src/lib/idempotent-operations.ts index f29460cd9..1b879c877 100644 --- a/packages/cli/src/lib/idempotent-operations.ts +++ b/packages/cli/src/lib/idempotent-operations.ts @@ -5,6 +5,8 @@ * `@sentry/api` catalog marks `idempotent` (from Sentry's * `x-sentry-idempotent` OpenAPI extension). Endpoints opt in on the server, * so the CLI no longer hard-codes which assemble and upload calls may retry. + * The generated file is committed and refreshed by `generate:schema`; commit + * it again after bumping `@sentry/api`. */ import generated from "../generated/idempotent-operations.json"; @@ -58,7 +60,7 @@ export function isIdempotentOperation(method: string, url: string): boolean { const normalized = apiPath.endsWith("/") ? apiPath : `${apiPath}/`; const upper = method.toUpperCase(); - matchers ??= (generated as IdempotentOperation[]).map(toMatcher); + matchers ??= (generated satisfies IdempotentOperation[]).map(toMatcher); return matchers.some( (matcher) => matcher.method === upper && matcher.pattern.test(normalized), ); From df7b3e559d404aaa55057931af8a3fb3c4ab4083 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:18:35 +0000 Subject: [PATCH 3/4] fix(cli): Read the idempotent catalog at runtime Drop the generated idempotent-operations.json. The CLI now imports @sentry/api/operations lazily on the first write, so nothing generated is checked in and code outside the CLI needs no generate step. Chunk uploads keep their explicit opt-in: they post to the URL the server's chunk-upload options return, which need not match the catalog path. --- packages/cli/script/generate-api-schema.ts | 27 ---------- .../src/generated/idempotent-operations.json | 14 ------ packages/cli/src/lib/api/chunk-upload.ts | 10 ++-- packages/cli/src/lib/api/infrastructure.ts | 5 +- packages/cli/src/lib/idempotent-operations.ts | 50 ++++++++++--------- packages/cli/src/lib/sentry-client.ts | 11 ++-- .../test/lib/idempotent-operations.test.ts | 44 +++++++++------- 7 files changed, 65 insertions(+), 96 deletions(-) delete mode 100644 packages/cli/src/generated/idempotent-operations.json diff --git a/packages/cli/script/generate-api-schema.ts b/packages/cli/script/generate-api-schema.ts index 673ce193d..7c86e6644 100644 --- a/packages/cli/script/generate-api-schema.ts +++ b/packages/cli/script/generate-api-schema.ts @@ -12,22 +12,16 @@ * Also reads SDK function names from the installed @sentry/api package to * map operationIds to their TypeScript SDK function names. * - * Also reads the `@sentry/api/operations` catalog to list the non-safe - * operations Sentry marks idempotent (`x-sentry-idempotent`), which the - * authenticated fetch retries on transient failures. - * * Usage: * tsx script/generate-api-schema.ts * * Output: * src/generated/api-schema.json - * src/generated/idempotent-operations.json */ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { operations } from "@sentry/api/operations"; /** Resolve the root directory of @sentry/api by finding its main entry point. */ function sentryApiDir(): string { @@ -38,7 +32,6 @@ function sentryApiDir(): string { } const OUTPUT_PATH = "src/generated/api-schema.json"; -const IDEMPOTENT_OUTPUT_PATH = "src/generated/idempotent-operations.json"; /** * Build the OpenAPI spec URL from the installed @sentry/api version. @@ -60,8 +53,6 @@ const PATH_PARAM_PATTERN = /\{(\w+)\}/g; export type { ApiEndpoint } from "../src/lib/api-schema.js"; import type { ApiEndpoint } from "../src/lib/api-schema.js"; -import type { IdempotentOperation } from "../src/lib/idempotent-operations.js"; -import { isRetryableMethod } from "../src/lib/retry-policy.js"; // --------------------------------------------------------------------------- // OpenAPI Types (minimal subset we need) @@ -240,21 +231,3 @@ await writeFile(OUTPUT_PATH, JSON.stringify(endpoints, null, 2)); console.log( `Generated ${OUTPUT_PATH} (${endpoints.length} endpoints, ${Math.round(JSON.stringify(endpoints).length / 1024)}KB)`, ); - -// Safe methods already retry, so only writes need listing; that keeps the -// file the CLI loads on every request tiny. -const idempotentOperations: IdempotentOperation[] = operations - .filter((op) => op.idempotent && !isRetryableMethod(op.method)) - .map((op) => ({ method: op.method, path: op.path })) - .sort( - (a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method), - ); - -await writeFile( - IDEMPOTENT_OUTPUT_PATH, - `${JSON.stringify(idempotentOperations, null, 2)}\n`, -); - -console.log( - `Generated ${IDEMPOTENT_OUTPUT_PATH} (${idempotentOperations.length} operations)`, -); diff --git a/packages/cli/src/generated/idempotent-operations.json b/packages/cli/src/generated/idempotent-operations.json deleted file mode 100644 index e2ce48199..000000000 --- a/packages/cli/src/generated/idempotent-operations.json +++ /dev/null @@ -1,14 +0,0 @@ -[ - { - "method": "POST", - "path": "/api/0/organizations/{organization_id_or_slug}/artifactbundle/assemble/" - }, - { - "method": "POST", - "path": "/api/0/organizations/{organization_id_or_slug}/chunk-upload/" - }, - { - "method": "POST", - "path": "/api/0/projects/{organization_id_or_slug}/{project_id_or_slug}/files/difs/assemble/" - } -] diff --git a/packages/cli/src/lib/api/chunk-upload.ts b/packages/cli/src/lib/api/chunk-upload.ts index 4846d6f61..426069ed8 100644 --- a/packages/cli/src/lib/api/chunk-upload.ts +++ b/packages/cli/src/lib/api/chunk-upload.ts @@ -395,8 +395,9 @@ export async function uploadMissingChunks(params: { } const limit = pLimit(serverOptions.concurrency); - // Sentry marks chunk-upload idempotent, so the fetch retries it. - const { fetch: authFetch } = getSdkConfig(regionUrl); + // Chunks are stored by SHA-1, so a replayed upload is a no-op. Opt in + // explicitly: the server-provided URL need not match the catalog path. + const { fetch: authFetch } = getSdkConfig(regionUrl, { idempotent: true }); await Promise.all( missingChunks.map((chunk) => @@ -488,8 +489,9 @@ export async function uploadMissingBufferChunks(params: { } const limit = pLimit(serverOptions.concurrency); - // Sentry marks chunk-upload idempotent, so the fetch retries it. - const { fetch: authFetch } = getSdkConfig(regionUrl); + // Chunks are stored by SHA-1, so a replayed upload is a no-op. Opt in + // explicitly: the server-provided URL need not match the catalog path. + const { fetch: authFetch } = getSdkConfig(regionUrl, { idempotent: true }); await Promise.all( missingChunks.map((chunk) => diff --git a/packages/cli/src/lib/api/infrastructure.ts b/packages/cli/src/lib/api/infrastructure.ts index 5688b58ea..c3573193d 100644 --- a/packages/cli/src/lib/api/infrastructure.ts +++ b/packages/cli/src/lib/api/infrastructure.ts @@ -212,9 +212,8 @@ export type ApiRequestOptions = { /** Manually validate each redirect before forwarding credentials. */ validatedRedirects?: boolean; /** - * Retry transient failures even for writes the server does not mark - * idempotent. Only for requests the server dedupes (see `idempotent` in - * sentry-client.ts). + * Retry transient failures even for writes. Only for requests the server + * dedupes (see `idempotent` in sentry-client.ts). */ idempotent?: boolean; }; diff --git a/packages/cli/src/lib/idempotent-operations.ts b/packages/cli/src/lib/idempotent-operations.ts index 1b879c877..a85b3d1e8 100644 --- a/packages/cli/src/lib/idempotent-operations.ts +++ b/packages/cli/src/lib/idempotent-operations.ts @@ -1,30 +1,20 @@ /** * Writes Sentry declares safe to replay. * - * `script/generate-api-schema.ts` copies the non-safe operations that the - * `@sentry/api` catalog marks `idempotent` (from Sentry's - * `x-sentry-idempotent` OpenAPI extension). Endpoints opt in on the server, - * so the CLI no longer hard-codes which assemble and upload calls may retry. - * The generated file is committed and refreshed by `generate:schema`; commit - * it again after bumping `@sentry/api`. + * Read from the `@sentry/api` operation catalog, which marks an operation + * `idempotent` when Sentry sets the `x-sentry-idempotent` OpenAPI extension. + * Endpoints opt in on the server, so the CLI does not hard-code which + * assemble calls may retry. */ -import generated from "../generated/idempotent-operations.json"; - -/** A non-safe operation Sentry marks idempotent. */ -export type IdempotentOperation = { - /** Uppercase HTTP method */ - method: string; - /** OpenAPI path template, such as `/api/0/organizations/{org}/chunk-upload/` */ - path: string; -}; +import { isRetryableMethod } from "./retry-policy.js"; type Matcher = { method: string; pattern: RegExp }; const TEMPLATE_SEGMENT = /^\{[^/]+\}$/; const REGEXP_SPECIAL = /[.*+?^${}()|[\]\\]/g; -function toMatcher({ method, path }: IdempotentOperation): Matcher { +function toMatcher(method: string, path: string): Matcher { const source = path .split("/") .map((segment) => @@ -33,22 +23,34 @@ function toMatcher({ method, path }: IdempotentOperation): Matcher { : segment.replace(REGEXP_SPECIAL, "\\$&"), ) .join("/"); - return { method: method.toUpperCase(), pattern: new RegExp(`^${source}$`) }; + return { method, pattern: new RegExp(`^${source}$`) }; } -let matchers: Matcher[] | undefined; +/** Loaded on the first write so read-only commands never parse the catalog. */ +let matchers: Promise | undefined; + +async function loadMatchers(): Promise { + const { operations } = await import("@sentry/api/operations"); + return operations + .filter((op) => op.idempotent && !isRetryableMethod(op.method)) + .map((op) => toMatcher(op.method, op.path)); +} /** - * Whether Sentry marks this request's operation idempotent. + * Whether Sentry marks this write's operation idempotent. * * Matches from the `/api/` segment on, so a self-hosted install served under - * a path prefix still matches. + * a path prefix still matches. Safe methods return false without loading the + * catalog; they retry regardless. * * @param method - HTTP method (any case) * @param url - Absolute request URL */ -export function isIdempotentOperation(method: string, url: string): boolean { - if (!URL.canParse(url)) { +export async function isIdempotentOperation( + method: string, + url: string, +): Promise { + if (isRetryableMethod(method) || !URL.canParse(url)) { return false; } const pathname = new URL(url).pathname; @@ -60,8 +62,8 @@ export function isIdempotentOperation(method: string, url: string): boolean { const normalized = apiPath.endsWith("/") ? apiPath : `${apiPath}/`; const upper = method.toUpperCase(); - matchers ??= (generated satisfies IdempotentOperation[]).map(toMatcher); - return matchers.some( + matchers ??= loadMatchers(); + return (await matchers).some( (matcher) => matcher.method === upper && matcher.pattern.test(normalized), ); } diff --git a/packages/cli/src/lib/sentry-client.ts b/packages/cli/src/lib/sentry-client.ts index 8a8176497..6a0c3b825 100644 --- a/packages/cli/src/lib/sentry-client.ts +++ b/packages/cli/src/lib/sentry-client.ts @@ -708,10 +708,9 @@ async function fetchWithRetry({ } const attemptFactory = await buildAttemptFactory(input, init); const timeoutMs = resolveTimeoutMs(fullUrl); - const methodRetryable = isRetryableMethod( - method, - idempotent || isIdempotentOperation(method, fullUrl), - ); + const methodRetryable = + isRetryableMethod(method, idempotent) || + (await isIdempotentOperation(method, fullUrl)); const urlPath = extractUrlPath(fullUrl); for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { @@ -790,8 +789,8 @@ type AuthenticatedFetchOptions = { /** * Caller asserts that replaying the request is harmless, so transient * failures retry for any method. Operations Sentry marks idempotent retry - * without this; set it only for writes the server dedupes but does not - * mark, such as preprod assemble polling. + * without this; set it for writes the server dedupes that the catalog + * cannot match, such as chunk uploads to the server-provided URL. */ idempotent?: boolean; }; diff --git a/packages/cli/test/lib/idempotent-operations.test.ts b/packages/cli/test/lib/idempotent-operations.test.ts index 67f4c5eb5..23bd5bca2 100644 --- a/packages/cli/test/lib/idempotent-operations.test.ts +++ b/packages/cli/test/lib/idempotent-operations.test.ts @@ -8,74 +8,82 @@ describe("isIdempotentOperation", () => { "/api/0/organizations/acme/chunk-upload/", "/api/0/organizations/acme/artifactbundle/assemble/", "/api/0/projects/acme/web/files/difs/assemble/", - ])("matches POST %s", (path) => { - expect(isIdempotentOperation("POST", `${REGION_URL}${path}`)).toBe(true); + ])("matches POST %s", async (path) => { + expect(await isIdempotentOperation("POST", `${REGION_URL}${path}`)).toBe( + true, + ); }); - test("ignores method case and a missing trailing slash", () => { + test("ignores method case and a missing trailing slash", async () => { expect( - isIdempotentOperation( + await isIdempotentOperation( "post", `${REGION_URL}/api/0/organizations/acme/chunk-upload`, ), ).toBe(true); }); - test("matches under a self-hosted path prefix", () => { + test("matches under a self-hosted path prefix", async () => { expect( - isIdempotentOperation( + await isIdempotentOperation( "POST", "https://sentry.example.com/sentry/api/0/organizations/acme/chunk-upload/", ), ).toBe(true); }); - test("ignores the query string", () => { + test("ignores the query string", async () => { expect( - isIdempotentOperation( + await isIdempotentOperation( "POST", `${REGION_URL}/api/0/organizations/acme/chunk-upload/?foo=bar`, ), ).toBe(true); }); - test("does not match other methods on a marked path", () => { + test("does not match other methods on a marked path", async () => { expect( - isIdempotentOperation( + await isIdempotentOperation( "PUT", `${REGION_URL}/api/0/organizations/acme/chunk-upload/`, ), ).toBe(false); }); - test("does not match writes Sentry leaves unmarked", () => { + test("does not match writes Sentry leaves unmarked", async () => { expect( - isIdempotentOperation( + await isIdempotentOperation( "POST", `${REGION_URL}/api/0/projects/acme/web/files/preprodartifacts/assemble/`, ), ).toBe(false); expect( - isIdempotentOperation( + await isIdempotentOperation( "POST", `${REGION_URL}/api/0/organizations/acme/issues/1/autofix/`, ), ).toBe(false); }); - test("does not let a template segment span several path segments", () => { + test("does not let a template segment span several path segments", async () => { expect( - isIdempotentOperation( + await isIdempotentOperation( "POST", `${REGION_URL}/api/0/organizations/acme/extra/chunk-upload/`, ), ).toBe(false); }); - test("returns false for URLs it cannot parse or that are not API paths", () => { - expect(isIdempotentOperation("POST", "not a url")).toBe(false); + test("returns false for safe methods, unparseable URLs, and non-API paths", async () => { expect( - isIdempotentOperation("POST", `${REGION_URL}/organizations/acme/`), + await isIdempotentOperation( + "GET", + `${REGION_URL}/api/0/organizations/acme/chunk-upload/`, + ), + ).toBe(false); + expect(await isIdempotentOperation("POST", "not a url")).toBe(false); + expect( + await isIdempotentOperation("POST", `${REGION_URL}/organizations/acme/`), ).toBe(false); }); }); From 01f7b7772d3f91bf13b6a3df5f1e7351c4c3f268 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:27:54 +0000 Subject: [PATCH 4/4] fix(cli): Generate the idempotent operations list at build time Go back to having generate:schema write the marked writes to the gitignored src/generated/idempotent-operations.json instead of shipping the whole @sentry/api catalog in the bundle. The apps/local tests import CLI modules that reach sentry-client.ts and ran before anything generated the file. Its test script now runs ensure-generated.mjs first, the same bootstrap the CLI's own scripts use. Chunk uploads keep their explicit opt-in: they post to the URL the server's chunk-upload options return, which need not match the catalog path. --- apps/local/package.json | 4 +- packages/cli/script/ensure-generated.mjs | 8 +++- packages/cli/script/generate-api-schema.ts | 27 +++++++++++ packages/cli/src/global.d.ts | 5 ++ packages/cli/src/lib/idempotent-operations.ts | 48 +++++++++---------- packages/cli/src/lib/sentry-client.ts | 7 +-- .../test/lib/idempotent-operations.test.ts | 44 +++++++---------- 7 files changed, 84 insertions(+), 59 deletions(-) diff --git a/apps/local/package.json b/apps/local/package.json index eab1c58ea..45e9fc771 100644 --- a/apps/local/package.json +++ b/apps/local/package.json @@ -8,8 +8,8 @@ "build": "tsc -b && vite build", "lint": "oxlint", "preview": "vite preview", - "test": "vitest run", - "test:integration": "vitest run src/App.integration.test.tsx", + "test": "pnpm --filter sentry exec node script/ensure-generated.mjs schema && vitest run", + "test:integration": "pnpm run test src/App.integration.test.tsx", "typecheck": "tsc -b", "sample": "tsx scripts/replay.ts" }, diff --git a/packages/cli/script/ensure-generated.mjs b/packages/cli/script/ensure-generated.mjs index 2a7cd780f..79bdeb2e5 100644 --- a/packages/cli/script/ensure-generated.mjs +++ b/packages/cli/script/ensure-generated.mjs @@ -18,7 +18,11 @@ import { execSync } from "node:child_process"; import { existsSync } from "node:fs"; -const SCHEMA = "src/generated/api-schema.json"; +/** Produced by `generate:schema`; imported at runtime by `src/`. */ +const SCHEMA_OUTPUTS = [ + "src/generated/api-schema.json", + "src/generated/idempotent-operations.json", +]; /** Produced by `generate:grammar`; imported by `src/lib/arg-parsing.ts`. */ const GRAMMAR_OUTPUTS = [ "src/generated/org-project-target-grammar.js", @@ -35,7 +39,7 @@ function run(script) { execSync(`pnpm run ${script}`, { stdio: "inherit" }); } -if (!existsSync(SCHEMA)) { +if (!SCHEMA_OUTPUTS.every(existsSync)) { run("generate:schema"); } if (process.argv[2] !== "schema" && !GRAMMAR_OUTPUTS.every(existsSync)) { diff --git a/packages/cli/script/generate-api-schema.ts b/packages/cli/script/generate-api-schema.ts index 7c86e6644..cf128099d 100644 --- a/packages/cli/script/generate-api-schema.ts +++ b/packages/cli/script/generate-api-schema.ts @@ -12,16 +12,22 @@ * Also reads SDK function names from the installed @sentry/api package to * map operationIds to their TypeScript SDK function names. * + * Also reads the `@sentry/api/operations` catalog to list the non-safe + * operations Sentry marks idempotent (`x-sentry-idempotent`), which the + * authenticated fetch retries on transient failures. + * * Usage: * tsx script/generate-api-schema.ts * * Output: * src/generated/api-schema.json + * src/generated/idempotent-operations.json */ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { operations } from "@sentry/api/operations"; /** Resolve the root directory of @sentry/api by finding its main entry point. */ function sentryApiDir(): string { @@ -32,6 +38,7 @@ function sentryApiDir(): string { } const OUTPUT_PATH = "src/generated/api-schema.json"; +const IDEMPOTENT_OUTPUT_PATH = "src/generated/idempotent-operations.json"; /** * Build the OpenAPI spec URL from the installed @sentry/api version. @@ -53,6 +60,8 @@ const PATH_PARAM_PATTERN = /\{(\w+)\}/g; export type { ApiEndpoint } from "../src/lib/api-schema.js"; import type { ApiEndpoint } from "../src/lib/api-schema.js"; +import type { IdempotentOperation } from "../src/lib/idempotent-operations.js"; +import { isRetryableMethod } from "../src/lib/retry-policy.js"; // --------------------------------------------------------------------------- // OpenAPI Types (minimal subset we need) @@ -231,3 +240,21 @@ await writeFile(OUTPUT_PATH, JSON.stringify(endpoints, null, 2)); console.log( `Generated ${OUTPUT_PATH} (${endpoints.length} endpoints, ${Math.round(JSON.stringify(endpoints).length / 1024)}KB)`, ); + +// Safe methods already retry, so only writes need listing; that keeps the +// file the CLI loads on every request tiny. +const idempotentOperations: IdempotentOperation[] = operations + .filter((op) => op.idempotent && !isRetryableMethod(op.method)) + .map((op) => ({ method: op.method, path: op.path })) + .sort( + (a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method), + ); + +await writeFile( + IDEMPOTENT_OUTPUT_PATH, + JSON.stringify(idempotentOperations, null, 2), +); + +console.log( + `Generated ${IDEMPOTENT_OUTPUT_PATH} (${idempotentOperations.length} operations)`, +); diff --git a/packages/cli/src/global.d.ts b/packages/cli/src/global.d.ts index 0d36faf7e..1433484f6 100644 --- a/packages/cli/src/global.d.ts +++ b/packages/cli/src/global.d.ts @@ -47,3 +47,8 @@ declare module "*/generated/api-schema.json" { const endpoints: unknown; export default endpoints; } + +declare module "*/generated/idempotent-operations.json" { + const operations: unknown; + export default operations; +} diff --git a/packages/cli/src/lib/idempotent-operations.ts b/packages/cli/src/lib/idempotent-operations.ts index a85b3d1e8..31df37154 100644 --- a/packages/cli/src/lib/idempotent-operations.ts +++ b/packages/cli/src/lib/idempotent-operations.ts @@ -1,20 +1,28 @@ /** * Writes Sentry declares safe to replay. * - * Read from the `@sentry/api` operation catalog, which marks an operation - * `idempotent` when Sentry sets the `x-sentry-idempotent` OpenAPI extension. - * Endpoints opt in on the server, so the CLI does not hard-code which - * assemble calls may retry. + * `script/generate-api-schema.ts` copies the non-safe operations that the + * `@sentry/api` catalog marks `idempotent` (from Sentry's + * `x-sentry-idempotent` OpenAPI extension). Endpoints opt in on the server, + * so the CLI does not hard-code which assemble calls may retry. */ -import { isRetryableMethod } from "./retry-policy.js"; +import generated from "../generated/idempotent-operations.json"; + +/** A non-safe operation Sentry marks idempotent. */ +export type IdempotentOperation = { + /** Uppercase HTTP method */ + method: string; + /** OpenAPI path template, such as `/api/0/organizations/{org}/chunk-upload/` */ + path: string; +}; type Matcher = { method: string; pattern: RegExp }; const TEMPLATE_SEGMENT = /^\{[^/]+\}$/; const REGEXP_SPECIAL = /[.*+?^${}()|[\]\\]/g; -function toMatcher(method: string, path: string): Matcher { +function toMatcher({ method, path }: IdempotentOperation): Matcher { const source = path .split("/") .map((segment) => @@ -23,34 +31,22 @@ function toMatcher(method: string, path: string): Matcher { : segment.replace(REGEXP_SPECIAL, "\\$&"), ) .join("/"); - return { method, pattern: new RegExp(`^${source}$`) }; + return { method: method.toUpperCase(), pattern: new RegExp(`^${source}$`) }; } -/** Loaded on the first write so read-only commands never parse the catalog. */ -let matchers: Promise | undefined; - -async function loadMatchers(): Promise { - const { operations } = await import("@sentry/api/operations"); - return operations - .filter((op) => op.idempotent && !isRetryableMethod(op.method)) - .map((op) => toMatcher(op.method, op.path)); -} +let matchers: Matcher[] | undefined; /** - * Whether Sentry marks this write's operation idempotent. + * Whether Sentry marks this request's operation idempotent. * * Matches from the `/api/` segment on, so a self-hosted install served under - * a path prefix still matches. Safe methods return false without loading the - * catalog; they retry regardless. + * a path prefix still matches. * * @param method - HTTP method (any case) * @param url - Absolute request URL */ -export async function isIdempotentOperation( - method: string, - url: string, -): Promise { - if (isRetryableMethod(method) || !URL.canParse(url)) { +export function isIdempotentOperation(method: string, url: string): boolean { + if (!URL.canParse(url)) { return false; } const pathname = new URL(url).pathname; @@ -62,8 +58,8 @@ export async function isIdempotentOperation( const normalized = apiPath.endsWith("/") ? apiPath : `${apiPath}/`; const upper = method.toUpperCase(); - matchers ??= loadMatchers(); - return (await matchers).some( + matchers ??= (generated as IdempotentOperation[]).map(toMatcher); + return matchers.some( (matcher) => matcher.method === upper && matcher.pattern.test(normalized), ); } diff --git a/packages/cli/src/lib/sentry-client.ts b/packages/cli/src/lib/sentry-client.ts index 6a0c3b825..0b780e8d1 100644 --- a/packages/cli/src/lib/sentry-client.ts +++ b/packages/cli/src/lib/sentry-client.ts @@ -708,9 +708,10 @@ async function fetchWithRetry({ } const attemptFactory = await buildAttemptFactory(input, init); const timeoutMs = resolveTimeoutMs(fullUrl); - const methodRetryable = - isRetryableMethod(method, idempotent) || - (await isIdempotentOperation(method, fullUrl)); + const methodRetryable = isRetryableMethod( + method, + idempotent || isIdempotentOperation(method, fullUrl), + ); const urlPath = extractUrlPath(fullUrl); for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { diff --git a/packages/cli/test/lib/idempotent-operations.test.ts b/packages/cli/test/lib/idempotent-operations.test.ts index 23bd5bca2..67f4c5eb5 100644 --- a/packages/cli/test/lib/idempotent-operations.test.ts +++ b/packages/cli/test/lib/idempotent-operations.test.ts @@ -8,82 +8,74 @@ describe("isIdempotentOperation", () => { "/api/0/organizations/acme/chunk-upload/", "/api/0/organizations/acme/artifactbundle/assemble/", "/api/0/projects/acme/web/files/difs/assemble/", - ])("matches POST %s", async (path) => { - expect(await isIdempotentOperation("POST", `${REGION_URL}${path}`)).toBe( - true, - ); + ])("matches POST %s", (path) => { + expect(isIdempotentOperation("POST", `${REGION_URL}${path}`)).toBe(true); }); - test("ignores method case and a missing trailing slash", async () => { + test("ignores method case and a missing trailing slash", () => { expect( - await isIdempotentOperation( + isIdempotentOperation( "post", `${REGION_URL}/api/0/organizations/acme/chunk-upload`, ), ).toBe(true); }); - test("matches under a self-hosted path prefix", async () => { + test("matches under a self-hosted path prefix", () => { expect( - await isIdempotentOperation( + isIdempotentOperation( "POST", "https://sentry.example.com/sentry/api/0/organizations/acme/chunk-upload/", ), ).toBe(true); }); - test("ignores the query string", async () => { + test("ignores the query string", () => { expect( - await isIdempotentOperation( + isIdempotentOperation( "POST", `${REGION_URL}/api/0/organizations/acme/chunk-upload/?foo=bar`, ), ).toBe(true); }); - test("does not match other methods on a marked path", async () => { + test("does not match other methods on a marked path", () => { expect( - await isIdempotentOperation( + isIdempotentOperation( "PUT", `${REGION_URL}/api/0/organizations/acme/chunk-upload/`, ), ).toBe(false); }); - test("does not match writes Sentry leaves unmarked", async () => { + test("does not match writes Sentry leaves unmarked", () => { expect( - await isIdempotentOperation( + isIdempotentOperation( "POST", `${REGION_URL}/api/0/projects/acme/web/files/preprodartifacts/assemble/`, ), ).toBe(false); expect( - await isIdempotentOperation( + isIdempotentOperation( "POST", `${REGION_URL}/api/0/organizations/acme/issues/1/autofix/`, ), ).toBe(false); }); - test("does not let a template segment span several path segments", async () => { + test("does not let a template segment span several path segments", () => { expect( - await isIdempotentOperation( + isIdempotentOperation( "POST", `${REGION_URL}/api/0/organizations/acme/extra/chunk-upload/`, ), ).toBe(false); }); - test("returns false for safe methods, unparseable URLs, and non-API paths", async () => { + test("returns false for URLs it cannot parse or that are not API paths", () => { + expect(isIdempotentOperation("POST", "not a url")).toBe(false); expect( - await isIdempotentOperation( - "GET", - `${REGION_URL}/api/0/organizations/acme/chunk-upload/`, - ), - ).toBe(false); - expect(await isIdempotentOperation("POST", "not a url")).toBe(false); - expect( - await isIdempotentOperation("POST", `${REGION_URL}/organizations/acme/`), + isIdempotentOperation("POST", `${REGION_URL}/organizations/acme/`), ).toBe(false); }); });