Skip to content

Commit 00fa748

Browse files
feat(operations): Expose idempotent flag in the operation catalog (#111)
Adds `idempotent: boolean` to every `ApiOperation` in `@sentry/api/operations`. Retrying clients such as the CLI and MCP can then decide from the catalog whether to resend a request after a timeout or 5xx, instead of hard-coding that per call site. Refs getsentry/sentry#128927. The Sentry side, getsentry/sentry#128930, now stamps `x-sentry-idempotent: true` on operations whose endpoint declares the method in `idempotent_methods`. **Semantics** - `GET`, `HEAD`, `OPTIONS`, `TRACE`: always `true` (RFC 9110 §9.2.1). Clients get one field to check, with no method special-casing. - Other methods: `true` only when the operation has `x-sentry-idempotent: true`. `PUT` and `DELETE` are not assumed idempotent because not every Sentry handler follows RFC 9110 §9.2.2. Sentry makes the same choice. - `false` means "not known to be idempotent", not "known to be unsafe". It is documented that way in the type docs and the README. **Current effect** Today's `openapi-derefed.json` has no `x-sentry-idempotent` markers. The endpoints declared so far (chunk upload, DIF assemble, artifact bundle assemble) are all `PRIVATE`, so nothing non-safe is marked yet. In the current build, 147 of 266 operations are `idempotent: true`, all safe methods. Non-safe operations will be marked as they're published with the declaration. **Verification** - `bun run build`, `bun run typecheck`, `bun test` (167 pass), `bun run test:node` - New test: a marked `POST` is `true`; an unmarked `POST`/`PUT`/`DELETE` is `false`. Existing catalog fixtures assert `true` for `GET` and `false` for an unmarked `POST`. <!-- junior-request-attribution:start --> via **BYK**. <!-- junior-request-attribution:end --> <!-- junior-session-footer:start --> <!-- junior-conversation-id:local%3Aweb%3A828b4ad5f18c5305232eec52 --> -- [View Junior Session in Sentry](https://sentry.sentry.io/explore/conversations/local%3Aweb%3A828b4ad5f18c5305232eec52/?project=4510944073809921) <!-- junior-session-footer:end --> Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> Co-authored-by: BYK <burak.kaya@sentry.io>
1 parent 42958f3 commit 00fa748

4 files changed

Lines changed: 33 additions & 1 deletion

File tree

‎README.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,9 @@ const logOperations = operations.filter(operation =>
9898
console.log(logOperations.map(({ functionName, method, path }) => ({ functionName, method, path })));
9999
```
100100

101-
Each `ApiOperation` records the actual exported `functionName`, original OpenAPI `operationId`, method, path, summary, description, tags, path parameter names, query metadata, and explicit deprecation flag. `ApiQueryParameter` includes the name, required flag, description, type, and declared enum values (item values for arrays). Operation parameters override inherited path parameters.
101+
Each `ApiOperation` records the actual exported `functionName`, original OpenAPI `operationId`, method, path, summary, description, tags, path parameter names, query metadata, explicit deprecation flag, and `idempotent` flag. `ApiQueryParameter` includes the name, required flag, description, type, and declared enum values (item values for arrays). Operation parameters override inherited path parameters.
102+
103+
`idempotent` tells retrying clients whether resending an identical request after a timeout or 5xx response is safe. It is always `true` for `GET`, `HEAD`, `OPTIONS`, and `TRACE`. Other methods are `true` only when Sentry marks the operation with the `x-sentry-idempotent` OpenAPI extension; `false` means not known to be idempotent, not known to be unsafe.
102104

103105
The catalog is generated from the same schema and SDK output as each release. Consumers own search, grouping, ranking, and command aliases; the catalog does not fetch a live schema or infer undocumented API behavior.
104106

‎lib/operation-catalog.mjs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
import { parseSdkOperations } from "./sdk-operations.mjs";
33

44
const HTTP_METHODS = new Set(["get", "post", "put", "patch", "delete", "head", "options", "trace"]);
5+
// RFC 9110 9.2.1: safe methods never change server state, so they are always idempotent.
6+
const SAFE_METHODS = new Set(["get", "head", "options", "trace"]);
57

68
/** Operation-level parameters replace path-level parameters with the same name/in. */
79
function queryParameters(pathItem, operation) {
@@ -53,6 +55,8 @@ export function extractOperations(spec, sdkSource) {
5355
pathParameters: [...path.matchAll(/\{([^}]+)\}/g)].map(match => match[1]),
5456
queryParameters: queryParameters(pathItem, operation),
5557
deprecated: operation.deprecated === true,
58+
// Other methods count only when Sentry marks them; PUT/DELETE handlers are not all idempotent.
59+
idempotent: SAFE_METHODS.has(method) || operation["x-sentry-idempotent"] === true,
5660
});
5761
}
5862
}

‎lib/operation-types.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,10 @@ export type ApiOperation = {
3434
readonly queryParameters: readonly ApiQueryParameter[];
3535
/** Whether OpenAPI explicitly marks the operation deprecated. */
3636
readonly deprecated: boolean;
37+
/**
38+
* Whether resending an identical request after a timeout or 5xx cannot change the outcome.
39+
* Always true for GET, HEAD, OPTIONS, and TRACE; other methods only when Sentry marks them
40+
* with `x-sentry-idempotent`. False means "not known to be idempotent".
41+
*/
42+
readonly idempotent: boolean;
3743
};

‎test/operations.test.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ describe("operation discovery", () => {
7070
{ name: "field", required: false, type: "array", enum: ["id", "timestamp"] },
7171
],
7272
deprecated: false,
73+
idempotent: true,
7374
}]);
7475

7576
const next = fixture(["logs", "spans", "new_dataset"]);
@@ -86,6 +87,7 @@ describe("operation discovery", () => {
8687
expect(grown.catalog).toHaveLength(2);
8788
expect(grown.catalog.find(operation => operation.path === newPath)).toMatchObject({
8889
functionName: "queryNewCapability", operationId: "queryNewCapability", method: "POST", deprecated: true,
90+
idempotent: false,
8991
});
9092
expect(grown.catalog.find(operation => operation.path === eventsPath)?.queryParameters[0]?.enum)
9193
.toEqual(["logs", "spans", "new_dataset"]);
@@ -98,6 +100,24 @@ describe("operation discovery", () => {
98100
.toThrow(`Multiple SDK operations for GET ${eventsPath}`);
99101
});
100102

103+
test("marks non-safe methods idempotent only when Sentry declares it", async () => {
104+
const response = { responses: { "200": { description: "OK" } } };
105+
const { catalog } = await generate({
106+
...fixture(),
107+
paths: {
108+
"/api/0/assemble/": { post: { operationId: "assemble", ...response, "x-sentry-idempotent": true } },
109+
"/api/0/things/": {
110+
post: { operationId: "createThing", ...response },
111+
put: { operationId: "updateThing", ...response },
112+
delete: { operationId: "deleteThing", ...response },
113+
},
114+
},
115+
});
116+
expect(Object.fromEntries(catalog.map(operation => [operation.functionName, operation.idempotent]))).toEqual({
117+
assemble: true, createThing: false, updateThing: false, deleteThing: false,
118+
});
119+
});
120+
101121
test("ships one catalog entry for every actual standalone SDK export", () => {
102122
expect(operations.map(operation => operation.functionName).sort()).toEqual(Object.keys(sdk).sort());
103123
const original = JSON.parse(readFileSync(new URL("../openapi-derefed.json", import.meta.url), "utf8"));

0 commit comments

Comments
 (0)