Skip to content

docs(seer): Document Autofix 402/409 error responses - #131067

Merged
BYK merged 1 commit into
masterfrom
docs/seer-autofix-402-409-schema
Nov 13, 2026
Merged

BYK merged 1 commit into
masterfrom
docs/seer-autofix-402-409-schema

Conversation

@sentry-junior

@sentry-junior sentry-junior Bot commented Nov 13, 2026

Copy link
Copy Markdown
Contributor

The OpenAPI schema for POST /organizations/{org}/issues/{issue_id}/autofix/ (startOrganizationIssueAutofix) listed only 202, 400, 401, 403 and 404, so @sentry/api types every error as unknown. The Sentry CLI and MCP in getsentry/toolkit map the 402 and 409 code values to user-facing messages, and they hardcode those codes today because the schema doesn't have them (see getsentry/toolkit#2207). Requested by @BYK.

This adds typed bodies for both statuses, with code as an enum:

  • 402 AutofixPaymentRequiredResponse: detail and a required code, either seer_out_of_credits or seer_out_of_quota. Every 402 this endpoint returns sets a code: the open_pr and default-step credit and quota checks, and the pr_iteration credit check in handle_ui_feedback.
  • 409 AutofixConflictResponse: detail, plus an optional code (scm_integration_required, repos_not_linked, or run_in_flight), and the optional run_id and sentry_run_id that come with run_in_flight. code is optional because some 409s carry only detail: re-running a step after a PR or coding agent started, paused PR iteration, and a run that is still being created.

The codes come from Literal types that now back the existing constants (SEER_OUT_OF_CREDITS_CODE, SEER_OUT_OF_QUOTA_CODE, RUN_IN_FLIGHT_CODE, and the keys of AUTOFIX_SETUP_REQUIRED_DETAIL). If a constant holds a code that the Literal doesn't list, mypy fails, so the enum and the runtime stay in sync. This is a typing and schema change only, with no change to runtime behavior.

The 422 that resolve_seer_run returns when a run failed to start is still undocumented. I left it out to keep this change to 402 and 409.

via @BYK.

--

View Session [Sentry]

Add typed 402 and 409 bodies to the startOrganizationIssueAutofix schema,
with each code as an enum, so generated clients like @sentry/api can type
them. The Literal types back the existing code constants, so mypy rejects
a code that the schema doesn't list. No runtime behavior change.

Co-Authored-By: BYK <126780+BYK@users.noreply.github.com>
@sentry-junior
sentry-junior Bot requested a review from a team as a code owner November 13, 2026 14:08
@sentry-junior
sentry-junior Bot requested a review from BYK November 13, 2026 14:08
@github-actions github-actions Bot added the Scope: Backend Automatically applied to PRs that change backend components label Nov 13, 2026

@BYK BYK left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be possible to prevent this kind of an omission/drift with a lint rule?

@BYK
BYK merged commit b70f2fd into master Nov 13, 2026
78 of 79 checks passed
@BYK
BYK deleted the docs/seer-autofix-402-409-schema branch November 13, 2026 15:06
@sentry-junior

sentry-junior Bot commented Nov 13, 2026

Copy link
Copy Markdown
Contributor Author

@BYK Partly. A lint rule can catch most of the omission, but not all of it. The body drift needs a type-checker or test change.

Missing statuses: a flake8 rule, similar to S021. In a method with @extend_schema, collect each Response(..., status=status.HTTP_4xx_*) or literal 4xx status it returns, and fail when that status isn't a key in responses=. That would have flagged the 402s and the setup/run_in_flight 409s here, because they're returned inline in post. It would miss statuses that come from helpers, because flake8 sees one file and one call at a time. Here that's handle_ui_feedback (402/409) and resolve_seer_run (409/422). It also can't see DRF exceptions raised deeper down. So it's a useful ratchet, not a guarantee.

Body drift: the mypy plugin is the better place. tools/mypy_helpers/plugin.py already narrows Response({...}) literals to the matching Response[TypedDict] arm. When no arm matches, it falls back, and this method's Response[ValidationErrorResponse] arm (dict[str, Any]) accepts any dict. That's why a wrong code in a 409 body wasn't a type error. I checked this while writing the PR. Narrowing that fallback, or erroring when a literal with a documented error status matches none of its TypedDict arms, would catch body drift. That probably needs a pass over endpoints that lean on the dict[str, Any] arm today.

Complete coverage: a runtime check in tests. Validate each response from endpoint tests against tests/apidocs/openapi-derefed.json. Fail when the status isn't documented for that operation, or the body doesn't match its schema. That catches the helper and exception paths too. Nothing like it exists today: the current apidocs ratchet (response_coverage.py) only checks descriptions and types of 2xx fields.

If you want one of these, the flake8 status check is the smallest. I'd start with it.

BYK added a commit that referenced this pull request Nov 13, 2026
…rm (#131074)

Requested by @BYK, following up on #131067
([comment](#131067 (comment))).

The new `AutofixConflictResponse` / `AutofixPaymentRequiredResponse`
TypedDicts in #131067 don't actually constrain the 409/402 bodies.
`GroupAutofixEndpoint.post` also returns
`Response[ValidationErrorResponse]`, which is `dict[str, Any]`. When a
dict literal matches no TypedDict arm, the plugin's union-narrowing hook
returns mypy's default `Response[dict[str, str]]`, and that type-checks
through the `dict[str, Any]` arm. So `{"detail": ..., "code": "bogus"}`
in the 409 branch passed mypy.

**Change.** In `tools/mypy_helpers/plugin.py`, a non-empty dict literal
that matches none of the TypedDict arms in the declared union now gets a
`[response-body]` error, but only when a loose (non-TypedDict) arm would
have let it through. Without a loose arm, mypy already reports the
incompatible return, so the plugin adds nothing. Two kinds of body are
still accepted: literals that match an arm, and non-literal bodies like
`as_validation_errors(serializer)` or a variable annotated
`ValidationErrorResponse`.

I also changed how the plugin checks literal values. Each one is now
inferred against its declared field type, silently, the way mypy probes
overloads. Before, a nested literal like `{"detail": {"message": ...,
"code": ...}}` could never match a nested TypedDict, and with the new
check that would have been a false positive.

**Why not narrow `ValidationErrorResponse`?** Its docstring explains
that `dict[str, list[str]]` loses DRF's nested error shapes. It's also
the arm for real `serializer.errors` bodies, so the fix belongs in the
literal check, not the alias.

**Blast radius.** With the check on, a full `mypy` run reported 21
errors in 9 files. The value-inference change cleared one of those, a
false positive on the nested `_CodedErrorResponse` in
`organization_auth_tokens.py`. The other 20 were inline DRF-style
field-error bodies such as `{"non_field_errors": [...]}`, `{"refs":
[...]}` and `{"role": "..."}`. Each one now has a TypedDict arm, with no
runtime change. I added `NonFieldErrorsResponse` to
`sentry.apidocs.response_types` because two endpoints share that shape;
the others are local `_...ErrorResponse` types. In
`project_team_details.py`, the `ValidationErrorResponse` arm only
covered one `{"detail": [...]}` literal, so I replaced it. getsentry
uses this same plugin through its `tools` symlink, and its full `mypy`
run is still clean.

With this branch, putting `"code": "bogus"` in the `run_in_flight` 409
in `group_ai_autofix.py` fails with `[response-body]`. The unmodified
file passes. On master, the bogus version passes. The regression tests
in `tests/tools/mypy_helpers/test_plugin.py` cover:
- a wrong `Literal` value
- a typo'd key
- correct literals, both raw and from a `Final` constant
- the annotated-variable opt-out
- nested TypedDicts
- no duplicate error when there's no loose arm

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

<!-- junior-session-footer:start -->
<!-- junior-conversation-id:agent%3Ab8a5ce8078be0c8781dcf80f7ae2a39e -->

--

[View
Session](https://junior-prod.sentry.dev/conversations/agent%3Ab8a5ce8078be0c8781dcf80f7ae2a39e)
[[Sentry]](https://sentry.sentry.io/explore/conversations/agent%3Ab8a5ce8078be0c8781dcf80f7ae2a39e/?project=4510944073809921)

<!-- junior-session-footer:end -->

Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com>
Co-authored-by: BYK <126780+BYK@users.noreply.github.com>
BYK added a commit that referenced this pull request Nov 13, 2026
…131072)

#131067 had to add the 402 and 409 responses that the Seer Autofix start
endpoint already returned but its `@extend_schema(responses=...)` never
listed. Until then, `@sentry/api` typed those errors as `unknown`. This
adds a flake8 rule, S034, that catches that kind of omission when the
endpoint is written. Requested by @BYK after the review question on
#131067.

**What S034 checks:** a class method with
`@extend_schema(responses={...})` that writes `return Response(...,
status=...)` with a 4xx status, either a literal or a
`status.HTTP_4xx_*` / `HTTP_4xx_*` name. If that status isn't a key in
`responses=`, the rule fires. `(status, media_type)` tuple keys and
digit-string keys count as documented. Run on the pre-#131067 version of
`group_ai_autofix.py`, it flags all six of the 402/409 returns that PR
documented.

**What it doesn't check**, as the rule's docstring says, so passing
doesn't mean every 4xx is documented:
- a 4xx that comes back from a helper, e.g. `return
handle_ui_feedback(...)`;
- a response assigned to a variable before it's returned;
- raised exceptions, which DRF renders as 4xx;
- `responses=` values it can't read statically (a name, a `**` spread, a
non-literal key). The method is skipped rather than guessed at.

Two follow-ups would close the gaps the rule leaves: tightening the mypy
plugin so error bodies are checked against their TypedDict (handled
separately), and a test-time check of responses against the generated
schema (#131073).

**Existing violations:** 95 inline returns in 43 methods across 30
files, all in `src/`. getsentry has no `extend_schema` endpoints. Every
one returns a status its schema didn't list, so I documented each with
the existing `RESPONSE_*` constant instead of baselining them: 400, 401,
403, 404, 409 and 429. This adds `RESPONSE_GONE` for the one 410. Like
S021–S023, the rule then runs with no baseline, and `tests/*` is exempt
the same way. These are schema-only additions, with no runtime behavior
change. Where a status has a known body shape, a typed response like the
ones in #131067 can replace the generic constant later.

The optional prod-vs-dev schema diff check fails by design here: the
schema gains those response entries, and nothing is removed.

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

<!-- junior-session-footer:start -->
<!-- junior-conversation-id:agent%3A255009b50ca40a74d79e4c03aef8af92 -->

--

[View
Session](https://junior-prod.sentry.dev/conversations/agent%3A255009b50ca40a74d79e4c03aef8af92)
[[Sentry]](https://sentry.sentry.io/explore/conversations/agent%3A255009b50ca40a74d79e4c03aef8af92/?project=4510944073809921)

<!-- junior-session-footer:end -->

Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com>
Co-authored-by: BYK <126780+BYK@users.noreply.github.com>
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.

1 participant