Skip to content

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

Closed

Description

@AlexeyShalaev

Version: servicewright 0.13.1.

HTTP_STATUS_BY_KIND maps ErrorKind.PRECONDITION_FAILED to 412 (core/errors.py:59). RFC 9110 §15.5.13 defines 412 narrowly: "one or more conditions given in the request header fields evaluated to false when tested on the server", meaning If-Match, If-Unmodified-Since and similar headers. A business precondition such as "the order is not paid yet" is not that, and a client, proxy or SDK that treats 412 as a failed conditional request (stale ETag: refetch and retry) will misread it.

Example from a service with both transports: an invoice requested for an unpaid order (kind = ErrorKind.PRECONDITION_FAILED) answers HTTP 412 and gRPC FAILED_PRECONDITION.

For comparison, google.rpc.Code maps FAILED_PRECONDITION to HTTP 400, and many APIs use 409 or 422 for "valid request, wrong resource state".

Proposal

Activity

  1. AlexeyShalaev commented on Oct 16, 2026

    @AlexeyShalaev
    MemberAuthor

    Agreed, 412 is the wrong row. Plan:

    PRECONDITION_FAILED will render as 400. I looked at 409 and 422 as well. What decides it for me is that every other row of HTTP_STATUS_BY_KIND 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 is the only row that breaks that rule. With 400, a service that exposes both transports answers the same status whether a client calls the HTTP side directly or goes through grpc-gateway or Envoy's transcoder in front of the gRPC side. 409 is already taken by CONFLICT, and 422 is what FastAPI's request validation answers, so either one would end up shared with another kind anyway.

    What we lose: the HTTP column stops being injective, because 400 now means INVALID or PRECONDITION_FAILED. Clients tell those apart by code in the problem body, which the docs already recommend for CONFLICT (duplicate key vs. state conflict). The gRPC column stays injective, and nothing in the code reverses the HTTP table.

    Since clients can branch on the status, this ships as a breaking change (fix!:, a minor bump while we're on 0.x), and the BREAKING CHANGE note will say how to keep 412 with a custom error_renderer for anyone who needs it. An inbound HTTPException(412) (a real conditional request) keeps answering 412, because its status is carried as an override.

    For the second point, the errors page will state that rule for the whole HTTP column and explain the two rows that need a sentence of their own (CONFLICT → ALREADY_EXISTS, and this one). I'm leaving the gRPC table's code comments alone for now because #90 is editing that file.

  2. AlexeyShalaev commented on Oct 16, 2026

    @AlexeyShalaev
    MemberAuthor

    Fix is up in #94. PRECONDITION_FAILED now answers 400. Your invoice example gets 400 with code: order_not_paid in the problem body, and the gRPC side stays FAILED_PRECONDITION. A test pins the rule for every kind: the HTTP status must be the one google.rpc.Code gives the gRPC status. 412 was the only row that broke it.

    It ships as fix!:, so it lands in the next minor. Anything on your side that branches on 412 for this error should branch on code instead. If a client can't change right away, the errors page now has a short renderer that keeps 412 for that kind. Real conditional requests that raise HTTPException(412) still get 412.

    The errors page now explains each row: one rule for the whole HTTP column, plus a paragraph each for CONFLICT → ALREADY_EXISTS and this one.

  3. added a commit that references this issue on Oct 17, 2026
    abd565f
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions