Skip to content

fix!: ErrorKind.PRECONDITION_FAILED renders as HTTP 400, not 412 - #94

Merged
AlexeyShalaev merged 1 commit into
masterfrom
fix/87-precondition-failed-status
Oct 16, 2026
Merged

AlexeyShalaev merged 1 commit into
masterfrom
fix/87-precondition-failed-status

Conversation

@AlexeyShalaev

Copy link
Copy Markdown
Member

Summary

HTTP_STATUS_BY_KIND mapped ErrorKind.PRECONDITION_FAILED to 412. RFC 9110 §15.5.13 keeps 412 for conditional request headers (If-Match, If-Unmodified-Since) that evaluate to false, and clients, proxies and SDKs read it as a stale ETag to refetch and retry. A business precondition ("order 42 is not paid yet") is not that.

PRECONDITION_FAILED now renders as 400.

Why 400 and not 409 or 422: every other row of the table is already the HTTP status google.rpc.Code gives the kind's gRPC status (INVALID_ARGUMENT 400, ALREADY_EXISTS 409, RESOURCE_EXHAUSTED 429, UNAVAILABLE 503, and so on). 412 was the only exception. With FAILED_PRECONDITION's 400, a service answers the same whether a client calls its HTTP side or goes through grpc-gateway or Envoy's transcoder in front of its gRPC side. 409 already belongs to CONFLICT, and 422 is what FastAPI's request validation answers, so either one would end up shared with another kind too.

The cost: the HTTP column is no longer injective, because 400 now means INVALID or PRECONDITION_FAILED. Nothing in the library reverses that table. The docs already send clients to the error code to tell CONFLICT cases apart, and the same now applies to 400.

An inbound HTTPException(412) (a real conditional request) still answers 412. Its status travels as status_override, and _KIND_BY_HTTP_STATUS keeps 412 → PRECONDITION_FAILED as the closest kind.

Breaking change

Clients can branch on the status, so this ships as fix!: and gets a minor bump under bump-minor-pre-major (0.13.x → 0.14.0). The BREAKING CHANGE footer says what to do. Anyone who needs 412 while clients migrate can keep it with a renderer, and the errors page now shows this one:

class Precondition412Renderer(ProblemDetailsRenderer):
    def render(self, info: ErrorInfo) -> RenderedError:
        if info.kind is ErrorKind.PRECONDITION_FAILED and info.status_override is None:
            info = dataclasses.replace(info, status_override=412)
        return super().render(info)

I ran it through a real FastApiEntrypoint. Default renderer: POST /invoice gives 400 order_not_paid, and an HTTPException(412) route gives 412. With the renderer, both give 412.

Rejected: a config option for this one row. It would be a new public knob for something a renderer already does in five lines.

Overlap with #90

None in code. This PR changes core/errors.py (HTTP table only), docs/concepts/errors.md, docs/agents.md and two test files. #90 changes adapters/grpc/errors.py, docs/adapters/grpc.md and the gRPC tests. GRPC_STATUS_BY_KIND and its code comment are untouched here. I merged this branch and #90 onto master in both orders: no conflicts, and test_error_parity.py and test_grpc.py pass on the result.

Tests

  • test_error_parity.py::test__error_kind__http_status__is_what_a_grpc_gateway_answers_for_its_grpc_status, parametrised over every ErrorKind, pins the rule above against google.rpc.Code's table. On master only [precondition_failed] fails.
  • test_fastapi.py::test__service_error_handler__precondition_failed__answers_400_not_412: the reporter's case over HTTP. Fails on master (412).
  • test_fastapi.py::test__http_exception_handler__412_from_a_conditional_request__keeps_412: guard, green before and after.

make check and make test pass (796 passed, 93.19 % coverage), and the docs build reports no issues.

Docs

The errors page explains every row with one rule (the HTTP status is google.rpc.Code's mapping of the gRPC status), with a paragraph each for the two rows that need it (CONFLICT → ALREADY_EXISTS, and this one) plus the renderer example. The HTTP_STATUS_BY_KIND comment and the agents page are updated to match.

Closes #87

RFC 9110 keeps 412 for conditional request headers (If-Match,
If-Unmodified-Since) that evaluate to false, and clients, proxies and SDKs
read it as a stale ETag to refetch and retry. A business precondition such
as "the order is not paid yet" is not that.

Every other row of HTTP_STATUS_BY_KIND is already the status google.rpc.Code
gives the kind's gRPC status; 412 was the only exception. With 400
(FAILED_PRECONDITION's mapping) a service answers the same over HTTP as
grpc-gateway or Envoy's transcoder does in front of its gRPC side. A test now
pins that rule for every kind, and the errors page explains it per row.
An HTTPException(412) raised for a real conditional request still answers 412.

Closes #87

BREAKING CHANGE: a ServiceError with kind=ErrorKind.PRECONDITION_FAILED now
answers HTTP 400 instead of 412 (gRPC stays FAILED_PRECONDITION). Clients that
branch on 412 should branch on the error code in the problem document. To
keep 412 while they migrate, pass a renderer that sets
status_override=412 for that kind; the errors page has a five-line example.
@AlexeyShalaev
AlexeyShalaev merged commit a37a72d into master Oct 16, 2026
7 checks passed
@AlexeyShalaev
AlexeyShalaev deleted the fix/87-precondition-failed-status branch October 16, 2026 14:56
AlexeyShalaev added a commit that referenced this pull request Oct 17, 2026
RFC 9110 keeps 412 for conditional request headers (If-Match,
If-Unmodified-Since) that evaluate to false, and clients, proxies and SDKs
read it as a stale ETag to refetch and retry. A business precondition such
as "the order is not paid yet" is not that.

Every other row of HTTP_STATUS_BY_KIND is already the status google.rpc.Code
gives the kind's gRPC status; 412 was the only exception. With 400
(FAILED_PRECONDITION's mapping) a service answers the same over HTTP as
grpc-gateway or Envoy's transcoder does in front of its gRPC side. A test now
pins that rule for every kind, and the errors page explains it per row.
An HTTPException(412) raised for a real conditional request still answers 412.

Closes #87

BREAKING CHANGE: a ServiceError with kind=ErrorKind.PRECONDITION_FAILED now
answers HTTP 400 instead of 412 (gRPC stays FAILED_PRECONDITION). Clients that
branch on 412 should branch on the error code in the problem document. To
keep 412 while they migrate, pass a renderer that sets
status_override=412 for that kind; the errors page has a five-line example.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ErrorKind.PRECONDITION_FAILED maps to HTTP 412, which RFC 9110 reserves for conditional request headers

1 participant