Repository navigation
fix!: ErrorKind.PRECONDITION_FAILED renders as HTTP 400, not 412 - #94
Merged
Merged
Conversation
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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
HTTP_STATUS_BY_KINDmappedErrorKind.PRECONDITION_FAILEDto 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_FAILEDnow renders as 400.Why 400 and not 409 or 422: every other row of the table is already the HTTP status
google.rpc.Codegives 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 toCONFLICT, 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
INVALIDorPRECONDITION_FAILED. Nothing in the library reverses that table. The docs already send clients to the error code to tellCONFLICTcases apart, and the same now applies to 400.An inbound
HTTPException(412)(a real conditional request) still answers 412. Its status travels asstatus_override, and_KIND_BY_HTTP_STATUSkeeps 412 →PRECONDITION_FAILEDas the closest kind.Breaking change
Clients can branch on the status, so this ships as
fix!:and gets a minor bump underbump-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:I ran it through a real
FastApiEntrypoint. Default renderer:POST /invoicegives400 order_not_paid, and anHTTPException(412)route gives412. With the renderer, both give412.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.mdand two test files. #90 changesadapters/grpc/errors.py,docs/adapters/grpc.mdand the gRPC tests.GRPC_STATUS_BY_KINDand its code comment are untouched here. I merged this branch and #90 onto master in both orders: no conflicts, andtest_error_parity.pyandtest_grpc.pypass 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 everyErrorKind, pins the rule above againstgoogle.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 checkandmake testpass (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. TheHTTP_STATUS_BY_KINDcomment and the agents page are updated to match.Closes #87