Skip to content

fix(rest/python): apply update request payment instruments instead of failing construction - #220

Open
vishkaty wants to merge 2 commits into
Universal-Commerce-Protocol:mainfrom
vishkaty:fix/checkout-update-payment-instruments-500
Open

fix(rest/python): apply update request payment instruments instead of failing construction#220
vishkaty wants to merge 2 commits into
Universal-Commerce-Protocol:mainfrom
vishkaty:fix/checkout-update-payment-instruments-500

Conversation

@vishkaty

@vishkaty vishkaty commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

What

A checkout update that submits payment.instruments returns a bare HTTP 500
instead of applying the instruments. A checkout create with the same shape
has the identical defect, unexercised by any existing test.

Root cause

rest/python/server/services/checkout_service.py:532 in update_checkout:

if checkout_req.payment:
  existing.payment = PaymentResponse(
    instruments=checkout_req.payment.instruments,
  )

checkout_req.payment.instruments holds
payment_instrument_update_request.SelectedPaymentInstrument instances,
the request side model that ucp_sdk generates for PaymentUpdateRequest.
PaymentResponse (imported as ucp_sdk.models.schemas.shopping.payment.Payment)
declares that same field as
list[payment_instrument.SelectedPaymentInstrument], the sibling response
class. The two classes share every field name and shape (id, handler_id,
type, billing_address, credential, display, selected) but are not
the same class and not a subclass of one another, so pydantic refuses to
construct PaymentResponse from an instance of the wrong one:

pydantic_core._pydantic_core.ValidationError: 1 validation error for Payment
instruments.0
  Input should be a valid dictionary or instance of SelectedPaymentInstrument

That ValidationError is neither a RequestValidationError nor a UcpError,
the two exception types this server maps to the UCP error envelope, so
FastAPI answers with its default unhandled exception response: a bare 500
with no body a caller can act on.

create_checkout at line 395 builds PaymentResponse the same way from
checkout_req.payment.instruments, where the request side class is instead
payment_instrument_create_request.SelectedPaymentInstrument. Same
construction, same collision, same 500. Every test in this repository that
sends payment on create sends instruments: [], and an empty list never
reaches the per item type check, so the create path 500 has been present
and untested since payment.instruments was first threaded through this
file.

Spec grounding

checkout.json marks the payment member of the update request
ucp_request: {create: "optional", update: "optional"}, so a platform
submitting payment on update is a conformant request, not an edge case.
checkout.md is explicit about what the field is for: "When the buyer
submits payment, the platform populates the payment.instruments array with
the collected instrument data." An update carrying instruments must apply
them and answer 200 with the instruments reflected, which is what the
added tests assert (checked against the currently pinned 2026-04-08 schema
and spec).

The fix

Both sites now dump the request side payment object to a dict first and
let PaymentResponse parse that dict, rather than handing it a foreign
model instance:

existing.payment = PaymentResponse(
  **checkout_req.payment.model_dump(exclude_none=True)
)

This mirrors the pattern this file already uses at the same request to
response boundary for buyer, context, signals, and discounts a few
lines below and above it (source_context.model_dump(exclude_none=True),
and so on), so the fix follows the established convention already in this file
rather than introducing a new one.

Class sweep table

The defect class is: a response model constructed from a request typed
object fields, where the two sides are different generated classes.
Every ...Response(...) construction in checkout_service.py that reaches
into request data was checked:

Site Data source Verdict
create_checkout payment (line 395) checkout_req.payment.instruments Converted (this PR)
update_checkout payment (line 532) checkout_req.payment.instruments Converted (this PR)
create_checkout / update_checkout LineItemResponse, ItemResponse scalar fields (li_req.id, li_req.item.id, li_req.quantity) Out of scope, no model instance crosses the boundary
create_checkout ShippingDestinationResponse (line 342) scalar fields (dest_req.address_country, and so on) Out of scope, same reason
update_checkout ShippingDestinationResponse (line 625) dest_req.model_dump(exclude_none=True) Already safe, already dumps to a dict before constructing
update_checkout ShippingDestinationResponse from stored customer address (line 634) ORM row fields, not request models Out of scope
FulfillmentResponseClass, FulfillmentMethod, FulfillmentGroup built from already constructed response objects and scalars Out of scope
TotalResponse (totals rebuild) scalars (amount, type) computed by the server Out of scope
cart_service.py no payment handling anywhere in the file Not applicable, carts do not carry payment

payment is the only field in this file where the response type is built
directly from a same shaped sibling model instead of a dict or scalars, on
both the create and the update path.

Testing

  • Added test_update_applies_payment_instruments and
    test_create_applies_payment_instruments to integration_test.py.
    signature_integration_test.py and cart_test.py both import and
    subclass IntegrationTest, so the two new tests also run signed and as
    part of the cart to checkout conversion path, for 8 passing test
    instances total from the 2 new test bodies.
  • Full suite: 253 passed, 3 warnings, 12 subtests passed (was 245 before
    the 8 new instances; 0 regressions).
  • Kill test: reverting only the checkout_service.py change and keeping
    the tests turns all 8 instances red with the same
    pydantic_core._pydantic_core.ValidationError: ... model_type this PR
    fixes. Restoring the fix turns them green again.
  • Pinned pre-commit run on both changed files: all hooks pass, including
    ruff and ruff-format as separate hooks.
  • Happy path client: simple_happy_path_client.py against a freshly
    seeded server completes discovery through order completion.
  • Direct wire check against the running server (not the test client): a
    create followed by PUT /checkout-sessions/{id} carrying a non empty
    payment.instruments now answers 200 with the instrument reflected in
    the response body, where it previously answered 500.

Dependency note

This branch is built on top of the fixture correction proposed in #219
(cherry picked here as 0da1797), which fixes an unrelated collision in
the shared checkout test helper (ShippingDestinationCreateRequest versus
its response sibling) that otherwise makes the whole suite fail to import
once a fresh install resolves ucp-sdk 0.4.6. If that PR has already
merged by the time this one is reviewed, this branch should rebase onto
main and drop the cherry picked commit; if not, this PR depends on it
landing first, or the same fix should be folded in ahead of merge.

Fixes #218

…test helper

_create_checkout_payload built shipping_destination.ShippingDestination
for FulfillmentMethodCreateRequest.destinations, a field typed as the
FulfillmentDestinationCreateRequest union. ucp-sdk 0.4.5 resolved that
union to the response variants (ShippingDestination, RetailLocation), so
the construction validated. 0.4.6 corrected the oneOf/anyOf codegen
(python-sdk#83) and the union now resolves to the matching
*CreateRequest variants, so the response class no longer validates
against a request field.

Since ucp-sdk is an unpinned dependency here, a fresh install now
resolves 0.4.6 and the shared helper fails for every caller
(integration_test.py, cart_test.py, signature_integration_test.py).
Swap the import and construction to
shipping_destination_create_request.ShippingDestinationCreateRequest,
matching the corrected union.
… failing construction

update_checkout built the response payment as
PaymentResponse(instruments=checkout_req.payment.instruments), handing the
response model a list of payment_instrument_update_request.
SelectedPaymentInstrument instances. PaymentResponse declares that field as
list[payment_instrument.SelectedPaymentInstrument], a sibling response
class with the same shape but a different identity, so pydantic rejects
the construction with an unhandled ValidationError: a bare 500, not the
UCP error envelope, on any update whose payment carries a non-empty
instruments array. create_checkout builds PaymentResponse the same way
from the create request and collapses the same way; every existing test
that touches payment sends instruments: [], so neither site was ever
exercised with a real instrument.

checkout.json marks payment ucp_request: {create: optional, update:
optional}, and checkout.md documents that submitting payment populates
payment.instruments with the collected instrument data, so update is a
normal place for a platform to submit payment and the server must apply
it, not 500.

Fix both sites by dumping the request-side payment to a dict and letting
PaymentResponse parse that, mirroring how buyer, context, signals, and
discounts already cross this same request to response boundary elsewhere
in this file.

Adds test_update_applies_payment_instruments and
test_create_applies_payment_instruments to integration_test.py, which
signature_integration_test.py and cart_test.py also pick up, giving 8
passing instances across the create and cart-to-checkout paths in both
signed and unsigned harness modes. Kill tested: reverting the service
change with the tests kept turns all 8 red with the same ValidationError;
restoring turns them green.

Builds on fix/test-helper-request-variants (samples 9c0ebef), cherry
picked here as its own commit, since a fresh ucp-sdk install resolves
0.4.6 and the shared checkout test helper needs that fix to import.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Checkout update carrying payment instruments returns a bare 500

3 participants