Skip to content

feat(api): tighten Response annotation on 6 endpoints (no-cast subset)#116717

Merged
azulus merged 1 commit into
masterfrom
jeremy/tighten-response-annotations
Jun 2, 2026
Merged

feat(api): tighten Response annotation on 6 endpoints (no-cast subset)#116717
azulus merged 1 commit into
masterfrom
jeremy/tighten-response-annotations

Conversation

@azulus

@azulus azulus commented Jun 2, 2026

Copy link
Copy Markdown
Member

Tighten six endpoint return annotations from -> Response to -> Response[T], where T is the type already named in the same method's inline_sentry_response_serializer(...) decorator.

Continues the Response[T] rollout from #116335 / #116433 / #116496 / #116538 / #116659. An audit found 29 endpoints today that pair a typed decorator with a bare annotation — invisible to both mypy and the structural linter. This PR ships the subset where either the body already matches structurally or a single source-typing fix (typed variable, explicit serializer instance, source retype) unblocks it, with no cast() calls. The remaining endpoints surface real source-typing gaps (untyped serialize() returns, untyped helper returns, pydantic .dict() boundaries) that get their own cohort PRs.

EMPTY_STATS_RESPONSE in timeseries.py was retyped from dict[str, Any] to StatsResponse so the events-timeseries endpoint's empty-projects fallback flows the correct type — the value {"timeSeries": []} already satisfied StatsResponse since meta is NotRequired.

@github-actions github-actions Bot added the Scope: Backend Automatically applied to PRs that change backend components label Jun 2, 2026
Comment thread src/sentry/releases/endpoints/organization_release_details.py Fixed
Continues the Response[T] rollout (#116335 / #116433 / #116496 / #116538
/ #116659). Tightens 6 endpoints where the `T` already named in the
`@extend_schema(responses=...)` decorator can flow into the return
annotation with zero `cast()` calls — either the body already matches
structurally, or a one-line source-typing fix unblocks it:

- organization_events_timeseries.py: tighten to `Response[StatsResponse]`.
  EMPTY_STATS_RESPONSE in `timeseries.py` retyped from `dict[str, Any]`
  to `StatsResponse` (its value `{"timeSeries": []}` is already a valid
  StatsResponse since `meta` is NotRequired).
- event_attachments.py: tighten to `Response[list[EventAttachmentSerializerResponse]]`.
- group_hashes.py: tighten to `Response[list[GroupHashesResult]]`.
- project_releases.py: tighten to `Response[list[ReleaseSerializerResponse]]`.
- discover_saved_queries.py: tighten to `Response[list[DiscoverSavedQueryResponse]]`,
  pass the (already-generic) `DiscoverSavedQueryModelSerializer()` instance
  explicitly so the call site is typed end-to-end.
- source_map_debug.py: tighten to `Response[SourceMapDebugResponse]`, type
  the locally-built `processed_exceptions: list[SourceMapDebugException]`
  and `processed_frames: list[SourceMapDebugFrame]` lists.

The remaining 23 endpoints from the original Surface A audit hit drift
that requires real source-typing work (Serializer[T] migrations for
EventSerializer / RuleSerializer / ReleaseSerializer / etc., typed return
annotations on `_handle_results` / `massage_outcomes_result`, richer
union arms for validation-error paths). Those land in follow-up PRs that
do the source typing properly — no cast() escape valves. The linter flip
to make bare-Response + typed-decorator a violation also waits until all
29 endpoints are tightened.

Verification:
- mypy: 0 errors across the 7 touched files
- prek: clean
- `sentry django spectacular`: byte-identical OpenAPI output vs master

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@azulus azulus force-pushed the jeremy/tighten-response-annotations branch from fb55c2e to ae487b8 Compare June 2, 2026 20:58
@azulus azulus changed the title feat(api): tighten 29 bare Response annotations + harden structural linter feat(api): tighten Response annotation on 6 endpoints (no-cast subset) Jun 2, 2026
@azulus azulus marked this pull request as ready for review June 2, 2026 21:07
@azulus azulus requested review from a team as code owners June 2, 2026 21:07
@azulus azulus merged commit c79609a into master Jun 2, 2026
64 of 65 checks passed
@azulus azulus deleted the jeremy/tighten-response-annotations branch June 2, 2026 21:48
azulus added a commit that referenced this pull request Jun 2, 2026
… helper

Continues the Response[T] rollout (#116717 was round 1). Three more
endpoints tightened via source typing — no `cast()` calls.

Two patterns demonstrated:

1. **Typed local variable** for locally-built dicts:
   - `project_filters.py`: `results: list[ProjectFilterResponse] = []`
   - `project_replay_jobs_delete.py` POST success path:
     `response: ReplayDeletionJobDetailResponse = {"data": response_data}`

2. **Validation-error union arm** for DRF `serializer.errors` paths.
   Added a shared `ValidationErrorResponse` type alias and
   `as_validation_errors(serializer)` helper to `apidocs/response_types.py`,
   alongside the existing `DetailResponse`. The helper projects DRF's
   `ReturnDict[Any, Any]` into a plain `dict[str, list[str]]` so a
   `Response[ValidationErrorResponse]` union arm is structurally satisfied
   without `cast()`:

       def post(...) -> Response[FooResponse] | Response[ValidationErrorResponse]:
           if not serializer.is_valid():
               return Response(as_validation_errors(serializer), status=400)

   Applied to `organization_trace_item_attributes.py` and
   `project_replay_jobs_delete.py` POST.

The `project_replay_jobs_delete.py` GET endpoints are tightened to
`Response[ReplayDeletionJobListResponse]` and
`Response[ReplayDeletionJobDetailResponse]` — clean tightenings.

Verification:
- mypy: 0 errors across all touched files
- prek: clean
- `sentry django spectacular`: byte-identical OpenAPI vs master

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
azulus added a commit that referenced this pull request Jun 2, 2026
… helper (#116736)

Tighten three more endpoint return annotations from `-> Response` to `->
Response[T]`, and add a shared `as_validation_errors()` helper in
`apidocs/response_types.py` for the recurring
`Response(serializer.errors, status=400)` pattern.

Round 2 of the rollout that started with #116717. Continues the
principle from that PR: no `cast()` calls — every tightening is
unblocked by typing the source (typed local variable, explicit
serializer instance, or a new union arm for validation errors). The
helper consolidates a 1-liner that was about to repeat across this PR
and future ones.

`ValidationErrorResponse` is intentionally `dict[str, Any]` and
`as_validation_errors` does `dict(serializer.errors)` (not a
value-traversal) — DRF emits flat, nested, and non-field-error shapes
interchangeably, and traversing the value collapses nested dicts to
keys. Caught by `test_post_validation_errors` on the nested
`ReplayDeletionJobCreateSerializer`.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
azulus added a commit that referenced this pull request Jun 3, 2026
Subscript `TagKeySerializer` and `TagValueSerializer` with their
already-typed response shapes, type the `get_group_tag_key` backend
method's return, and tighten `group_tagkey_details.py` to
`Response[TagKeySerializerResponse]`.

Round 3 of the Response[T] rollout (after #116717 / #116736). Same
no-cast principle: each tightening fixes the underlying source-typing
gap rather than papering over it at the call site. The endpoint calls
`serialize(group_tag_key, request.user, serializer=TagKeySerializer())`;
without a typed return on `get_group_tag_key`, mypy resolved
`serialize()` to the `Any` fallback overload and the body-is-Any plugin
fired.

Typing the backend method's return as `GroupTagKey | TagKey` (the union
the Snuba implementation already concretely returns) lets mypy pick the
typed `Serializer[T]` overload and flow `TagKeySerializerResponse`
through end-to-end.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
azulus added a commit that referenced this pull request Jun 3, 2026
Three patterns, source-typing only — no cast(), no behavior change:
annotation-only where the body matched the declared T; union with
ValidationErrorResponse + as_validation_errors() for the
`Response(serializer.errors, 400)` path; one Serializer[T] subscript on
ExternalActorSerializer to unlock typed-overload resolution.

Round 5 of the rollout — same shape as #116717 / #116736 / #116743.
azulus added a commit that referenced this pull request Jun 3, 2026
Tighten 14 endpoints from `-> Response` to `-> Response[T]`.
Source-typing only — no `cast()`, no behavior change.

Three patterns: annotation-only where the body already matches the
declared T; `Response[T] | Response[ValidationErrorResponse]` with the
`as_validation_errors()` helper for endpoints whose 400 path returns
`serializer.errors`; one missing `Serializer[T]` subscript on
`ExternalActorSerializer` to unlock typed-overload resolution.

Round 5 of the rollout — same shape as #116717 / #116736 / #116743.
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.

3 participants