Skip to content

fix(rest/python): answer five client reachable faults with 4xx or a result instead of a bare 500 - #257

Merged
damaz91 merged 2 commits into
Universal-Commerce-Protocol:mainfrom
vishkaty:sweep-500
Oct 19, 2026
Merged

damaz91 merged 2 commits into
Universal-Commerce-Protocol:mainfrom
vishkaty:sweep-500

Conversation

@vishkaty

Copy link
Copy Markdown
Contributor

Five client or agent reachable requests answer 500 text/plain Internal Server Error instead
of a 4xx envelope or a result. Each reaches code that raises a type no handler covers:
server.py registers handlers for RequestValidationError and UcpError only, so anything
else reaches the framework default.

This class has been fixed one site at a time before, in #156, #167, #196 and #220. The five
below are not covered by those. Two sibling conditions in the same class are already covered by
open pull requests and are deliberately left out here: a malformed payment body on complete
(#240) and the idempotency key IntegrityError (#245).

Probed on a freshly cloned and seeded reference at main b7d7a549, with the agent profile
served by a local stub.

request observed expected cause
create with a client sent platform member 500 201, member ignored TypeError: models.UnifiedCheckout() got multiple values for keyword argument 'platform'
create with a client sent ap2 member of the wrong type 500 201, member ignored ValidationError for the ap2 field on the response model
create whose agent profile has a non URL webhook_url 500 201, value dropped ValidationError: 1 validation error for PlatformSchema
complete with a card number that is an integer 500 400 INVALID_REQUEST TypeError: 'int' object is not subscriptable
update reusing a destination id with new content 500 200 sqlite3.IntegrityError: UNIQUE constraint failed: customer_addresses.id

Controls, same run: a plain create is 201; the same profile with a valid webhook_url is 201;
the same card number as a string is already a correct 400 envelope; a first update with that
destination id is 200.

Why each happens.
create_checkout unpacks the request dump with **checkout_data while also passing
platform=, and platform was the one keyword missing from the exclude set that exists for
exactly this reason. Nearly every SDK model is configured extra="allow" (189 of 192), so a
client sent member survives into the dump. checkout.json at v2026-04-08 declares no platform
member, so this is an unknown member a server should tolerate rather than a field it may trust.

ap2 reaches the same line by a different route. It is not declared on the create request, so a
client value survives as an extra; it is a declared field on the response model through the AP2
mixin, so a wrong typed value is validated and rejected there. The service never sets it on
create, and the schema annotates the member as omitted on create, so excluding it is safe. One
consequence beyond the error: a well formed client ap2 was previously stored and echoed back on
create, and is now dropped there. That follows the annotation, and the complete path, where the
mandate is the one that matters, is untouched.

extract_webhook_url returns the raw profile value and the route feeds it to PlatformSchema,
whose webhook_url is a required URL type. The module already declares a UcpConfig model with
HttpUrl for this purpose and does not use it. This fires on every create or update by that
agent, not only on create.

_process_payment slices number[-4:] for a log line, and the credential model the route builds
admits extras of any type.

save_customer_address uses the platform chosen destination id as the primary key of
customer_addresses and looks rows up by content only, so a second use of one id collides.

Why the suite did not catch it. The existing integration tests send well formed members and a
valid profile, so none of the five conditions is constructed anywhere. Each fix below ships with
the test that reproduces it.

The fixes, 48 added and 2 removed lines of production code across 7 hunks in 3 files.
platform and ap2 join the exclude set. The profile value is gated through the UcpConfig
model the module already declares and dropped with a log line when it fails, which is how
extract_webhook_url already treats every other profile problem. A non string card number raises
InvalidRequestError. save_customer_address updates the row in place when the same customer
reuses an id, and takes a fresh internal key when another customer already holds that id.

In that last case the response still carries the id the platform sent. The row key is internal,
and echoing a different id back would answer 200 while leaving selected_destination_id pointing
at no destination, so no shipping option is quoted and the checkout cannot reach
ready_for_complete. A test asserts the id is echoed unchanged in that case. One edge is worth naming: when the
same buyer sends new content that already exists under another of their own ids, the content match
still wins and the stored row for the other id keeps its own content, so a later listing shows it
unchanged. Reordering the two checks would create two rows with identical content for one
customer, which the single row lookup would then reject, so the match order is left alone.

One thing to disclose about the webhook_url gate: UcpConfig uses HttpUrl, which is stricter
than the AnyUrl on PlatformSchema. A value such as an ftp URL, or an https URL long enough
to exceed the length bound, is stored today and would be dropped with a log line instead. Nothing
crashes either way, and if gating on PlatformSchema itself is preferred so the two agree
exactly, that is a one line change.

One of these is a contract choice rather than a crash to remove. For the malformed webhook_url
the patch drops the value, matching the lenient handling of every other profile fault in that
function. The Error Codes table maps profile_malformed to 422, so a hard error is also
defensible. Happy to switch to 422 if that is preferred.

Tests. Six cases added to rest/python/server/integration_test.py in the existing idiom.
Against the unfixed tree they fail with the exceptions above. With the fixes they pass. Each fix
was then excised with its test kept, and each test went red alone, so none of them passes without
the code it covers.

Checks run locally on this branch. uv run pytest -v in rest/python/server: 307 passed and
12 subtests passed, against 283 and 12 on main. The difference is the six new cases collected four
times each, because IntegrationTest is imported by cart_test.py, where it is collected both
under its own name and through a subclass, and by signature_integration_test.py. The seeded
server boots and simple_happy_path_client.py completes successfully. The pinned
pre-commit run --all-files passes all hooks. The upstream python-server-tests workflow was also
run against this exact branch and passed. Re-probing the five requests against the fixed server
gives 201, 201, 201, the 400 envelope, and 200.

The nodejs twin was probed on the wire for all five. It answers 201 for the platform member
and for a wrong typed ap2, and 200 for the reused destination id, so those do not arise there.
The integer card number is neither validated nor read: the generated credential schema declares
only type and is not strict, so validation passes with number present, and the complete
handler reads only type. The outcome is identical to a string number. For the malformed
webhook_url it answers 201 and stores the raw invalid value, which looks like a separate
question for that server rather than part of this change.

@damaz91 damaz91 added status:needs-triage Signal that the PR is ready for human triage status:under-review and removed status:needs-triage Signal that the PR is ready for human triage labels Oct 18, 2026
…esult instead of a bare 500

Five requests reach code that raises outside the two registered exception
handlers (RequestValidationError and UcpError), so FastAPI answers each
with its default "Internal Server Error" text body:

1. a create that carries a `platform` member: the request dump is unpacked
   with **checkout_data while platform= is also passed as a keyword, so
   UnifiedCheckout() raised TypeError. `platform` joins the exclude set.
2. an agent profile whose webhook_url is not a URL: the raw value reached
   PlatformSchema, whose webhook_url is AnyUrl, and raised ValidationError
   on every create by that agent. The value is now gated through the
   UcpConfig model the module already declares, and dropped with a log
   line, which is how
   the function already treats every other profile problem.
3. a complete whose card credential carries a non string `number`: the
   last four digits slice raised TypeError. It is now INVALID_REQUEST 400.
4. an update that reuses a platform chosen destination id with new content,
   or another buyer reusing the id: the id is the primary key of
   customer_addresses and only content was looked up, so the insert raised
   IntegrityError. The row is updated in place for its owner and a fresh
   key is assigned when another customer holds the id. The response still
   carries the id the platform sent, because the row key is internal and
   echoing a different id would leave selected_destination_id matching no
   destination, which stalls the checkout more quietly than the error.
5. a create that carries an `ap2` member: it is not declared on the create
   request so a client value survives as an extra, and it IS a declared
   field on the response model, so a wrong typed value raised
   ValidationError there. The schema annotates the member create: omit and
   the server never sets it on create, so `ap2` joins the exclude set too.

Each case has a test that fails with the original exception before the
fix and passes after it.

@damaz91 damaz91 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks @vishkaty! Rebased onto main to resolve the integration_test.py conflict and updated dest_data.setdefault("id", saved_id) to if not dest_data.get("id"): dest_data["id"] = saved_id so empty-string destination IDs ("id": "") continue to adopt saved_id in conformance tests. LGTM!

@damaz91
damaz91 merged commit 360adbc into Universal-Commerce-Protocol:main Oct 19, 2026
12 checks passed
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.

4 participants