Skip to content

feat(apidocs): Mark idempotent operations with x-sentry-idempotent - #128930

Merged
BYK merged 2 commits into
masterfrom
feat/apidocs-idempotent-operations
Oct 27, 2026
Merged

BYK merged 2 commits into
masterfrom
feat/apidocs-idempotent-operations

Conversation

@sentry-junior

@sentry-junior sentry-junior Bot commented Oct 27, 2026

Copy link
Copy Markdown
Contributor

Lets endpoints declare which non-safe methods are idempotent, and publishes that as an OpenAPI operation extension. Generated clients such as @sentry/api, and through it the CLI and MCP, can then retry those operations on timeouts and 5xx responses instead of hard-coding per-call opt-ins.

Refs #128927. Context: getsentry/toolkit#1677.

What changes

  • Endpoint.idempotent_methods: frozenset[HTTP_METHOD_NAME] (default empty). Listing a method there means resending the identical request after a timeout or 5xx cannot change the outcome.
  • custom_preprocessing_hook collects declarations for every endpoint, like silos, and resets them on every build. custom_postprocessing_hook stamps "x-sentry-idempotent": true on matching operations. Operations without a declaration get no marker.
  • The build fails if an endpoint lists a safe method (GET/HEAD/OPTIONS/TRACE, RFC 9110 §9.2.1) or a method it does not declare in publish_status.
  • PUT/DELETE are not marked automatically. Not every handler lives up to RFC 9110 §9.2.2, so the absence of the marker means "not known to be idempotent".
  • Documented in src/sentry/apidocs/README.md.

Declared on

  • ChunkUploadEndpoint POST: blobs are unique per checksum.
  • DifAssembleEndpoint POST and OrganizationArtifactBundleAssembleEndpoint POST: assemble state is cached per checksum, so a resend reports it instead of starting a second job.

ProjectPreprodArtifactAssembleEndpoint is deliberately not marked. create_preprod_artifact only reuses a row while it is still UPLOADING, so a resend after that creates a second artifact and enqueues another assemble task.

Open questions for review

  • All three declared endpoints are PRIVATE, so the public schema doesn't change yet. The declarations are inert until those endpoints are published, or until @sentry/api builds from a schema that includes them. Which route do we want?
  • Name and shape: a boolean x-sentry-idempotent, or a richer x-sentry-retry object?

Verification

  • ruff check and ruff format --check are clean on the changed files.
  • New IdempotentOperationsTest cases in tests/apidocs/test_hooks.py cover: marking only declared methods, collecting unpublished methods, resetting between builds, and both validation errors.
  • I couldn't run the Sentry pytest/mypy environment in my sandbox, so I ran the same scenarios against the real hooks.py with stubbed imports, and they pass. CI is the authoritative run.

via BYK.

--

View Junior Session [Sentry]

Endpoints can list non-safe methods in `idempotent_methods` when resending
the identical request after a timeout or 5xx cannot change the outcome. The
schema hooks stamp `"x-sentry-idempotent": true` on those operations so
generated clients such as `@sentry/api` can retry them instead of
hard-coding per-call opt-ins.

The build fails if an endpoint lists a safe method (RFC 9110 9.2.1) or a
method it does not declare in `publish_status`.

Declare it for chunk upload and the DIF and artifact bundle assemble
endpoints: blobs and assemble state are keyed by checksum, so a resend
reports the existing state. Preprod assemble is left out because a resend
after the artifact leaves UPLOADING creates a second artifact.

Refs #128927

Co-Authored-By: BYK <burak.kaya@sentry.io>
@github-actions github-actions Bot added the Scope: Backend Automatically applied to PRs that change backend components label Oct 27, 2026
@BYK
BYK marked this pull request as ready for review October 27, 2026 21:10
@BYK
BYK requested review from a team as code owners October 27, 2026 21:10
@BYK
BYK merged commit 5f4177c into master Oct 27, 2026
89 checks passed
@BYK
BYK deleted the feat/apidocs-idempotent-operations branch October 27, 2026 22:10
BYK added a commit to getsentry/sentry-api-schema that referenced this pull request Oct 27, 2026
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>
BYK added a commit that referenced this pull request Oct 28, 2026
…ental (#128999)

Follow-up to #128930. Publishes the endpoints that declare
`idempotent_methods` so the `x-sentry-idempotent` markers actually reach
the public schema, and through it `@sentry/api`
(getsentry/sentry-api-schema#111 already reads the extension).

Refs #128927.

**What changes**

All four operations are `PUBLIC_EXPERIMENTAL` and fully documented:

- `GET /organizations/{org}/chunk-upload/`
(`getOrganizationChunkUploadOptions`): the upload URL and limits.
Clients need it before they upload, so it's published with the POST.
- `POST /organizations/{org}/chunk-upload/`
(`uploadOrganizationChunks`): multipart body described by a docs-only
`ChunkUploadSerializer`, following the `ReleaseFileUploadSerializer`
precedent.
- `POST /projects/{org}/{project}/files/difs/assemble/`
(`assembleProjectDebugFiles`)
- `POST /organizations/{org}/artifactbundle/assemble/`
(`assembleOrganizationArtifactBundle`)

The three POSTs carry `x-sentry-idempotent: true`. GET doesn't, because
safe methods are implicit.

**Notes for review**

- **One schema for validation and docs.** The assemble endpoints'
jsonschema moved to module constants that both validation and
`extend_schema` use, so the docs can't drift from what's enforced.
Validation behavior is unchanged.
- **DIF body is a map keyed by checksum.** OpenAPI 3.0 has no
`patternProperties`, so the docs version uses `additionalProperties`,
and the checksum key format is explained in the description.
`_validate_request_body` raised `KeyError: 'properties'` on map bodies.
It now checks field descriptions under the map's `additionalProperties`.
The new `MapRequestBodyTest` covers this.
- **Types.** Added response TypedDicts and typed `batch_assemble`.
`state` is `str`, not a `Literal`, because `ChunkFileState` values are
plain strings.
- **`accept` is now a list.** It was a tuple, which spectacular
documents as a fixed-length array, so the GET options response returns
`list(CHUNK_UPLOAD_ACCEPT)` instead. The JSON output is the same.
- **File parts show as `format: uri`.** That's how spectacular renders
`FileField`, and it matches the existing release-file upload docs. Happy
to override it to `binary` if reviewers prefer.

**Verification**

- `sentry django spectacular --validate --fail-on-warn` passes. The
deref build and `openapi-examples-validator --no-additional-properties`
pass with no errors.
- `ruff check`, `ruff format --check`, `flake8`, and `mypy` are clean on
the changed files.
- Passing: `tests/apidocs/test_hooks.py`,
`test_operation_id_uniqueness.py`, `test_chunk_upload.py`,
`test_organization_artifactbundle_assemble.py` (78 tests).
- `test_dif_assemble.py` needs an objectstore server, which my sandbox
doesn't have, so CI is the authoritative run there. I checked
`parse_assemble_request_payload` directly instead: it accepts valid
payloads and rejects bad checksum keys, a missing `name`, non-hex
chunks, and invalid `debug_id`, the same as before.

<!-- junior-request-attribution:start -->
via **BYK**.
<!-- junior-request-attribution:end -->

<!-- junior-session-footer:start -->
<!-- junior-conversation-id:slack%3AC08J1NSPU6S%3A1792772794.175999 -->

--

[View Junior
Session](https://junior-prod.sentry.dev/conversations/slack%3AC08J1NSPU6S%3A1792772794.175999)
[[Sentry]](https://sentry.sentry.io/explore/conversations/slack%3AC08J1NSPU6S%3A1792772794.175999/?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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Scope: Backend Automatically applied to PRs that change backend components

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants