Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions rest/nodejs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,21 @@ app.use(async (c: Context, next: () => Promise<void>) => {
// Ideally we'd parse and check compatibility.
if (clientVersion > serverVersion) {
return c.json(
{ error: `Unsupported UCP version: ${clientVersion}` },
400
{
ucp: {
version: serverVersion,
status: "error",
},
messages: [
{
type: "error",
code: "VERSION_UNSUPPORTED",
content: `Version ${clientVersion} is not supported. This merchant implements version ${serverVersion}.`,
severity: "unrecoverable",
},
],
},
422
);
}
}
Expand Down
35 changes: 9 additions & 26 deletions rest/python/server/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,7 @@

import config
import db
from exceptions import UcpErrorDetail
from exceptions import UcpErrorDetailItem
from exceptions import UcpError
from exceptions import UcpVersionError
from fastapi import Depends
from fastapi import Header
Expand Down Expand Up @@ -73,10 +72,7 @@ async def validate_ucp_headers(ucp_agent: str):
try:
server_date = parse_ucp_version(server_version)
except UcpVersionError as exc:
raise HTTPException(
status_code=500,
detail=exc.to_detail().model_dump(),
) from exc
raise UcpError("Server version configuration is invalid") from exc

# Default to server version if UCP-Agent omits version=.
agent_version = server_version
Expand All @@ -92,29 +88,16 @@ async def validate_ucp_headers(ucp_agent: str):
if match:
# Group 1 is quoted value, Group 2 is unquoted value
agent_version = (match.group(1) or match.group(2)).strip()
try:
agent_date = parse_ucp_version(agent_version)
except UcpVersionError as exc:
raise HTTPException(
status_code=400,
detail=exc.to_detail().model_dump(),
) from exc
agent_date = parse_ucp_version(agent_version)

if agent_date > server_date:
raise HTTPException(
raise UcpVersionError(
message=(
f"Version {agent_version} is not supported. This merchant"
f" implements version {server_version}."
),
code="VERSION_UNSUPPORTED",
status_code=422,
detail=UcpErrorDetail(
errors=[
UcpErrorDetailItem(
code="VERSION_UNSUPPORTED",
message=(
f"Version {agent_version} is not supported. This merchant"
f" implements version {server_version}."
),
severity="critical",
)
]
).model_dump(),
)


Expand Down
93 changes: 64 additions & 29 deletions rest/python/server/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,31 +17,37 @@
from pydantic import BaseModel


class UcpErrorDetailItem(BaseModel):
"""Details of a single error."""
class UcpMessageError(BaseModel):
"""Details of a single error, matching message_error.json schema."""

type: str = "error"
code: str
message: str
severity: str = "critical"
content: str
severity: str


class UcpErrorDetail(BaseModel):
"""Top-level error response payload."""
class UcpErrorResponse(BaseModel):
"""Top-level error response payload, matching error_response.json schema."""

status: str = "error"
errors: list[UcpErrorDetailItem]
ucp: dict # Will contain {"version": ..., "status": "error"}
messages: list[UcpMessageError]


class UcpError(Exception):
"""Base class for all UCP exceptions."""

def __init__(
self, message: str, code: str = "INTERNAL_ERROR", status_code: int = 500
self,
message: str,
code: str = "INTERNAL_ERROR",
status_code: int = 500,
severity: str = "unrecoverable",
):
"""Initialize UcpError."""
self.message = message
self.code = code
self.status_code = status_code
self.severity = severity
super().__init__(self.message)


Expand All @@ -50,66 +56,95 @@ class ResourceNotFoundError(UcpError):

def __init__(self, message: str):
"""Initialize ResourceNotFoundError."""
super().__init__(message, code="RESOURCE_NOT_FOUND", status_code=404)
super().__init__(
message,
code="RESOURCE_NOT_FOUND",
status_code=404,
severity="unrecoverable",
)


class IdempotencyConflictError(UcpError):
"""Raised when an idempotency key is reused with different parameters."""

def __init__(self, message: str):
"""Initialize IdempotencyConflictError."""
super().__init__(message, code="IDEMPOTENCY_CONFLICT", status_code=409)
super().__init__(
message,
code="IDEMPOTENCY_CONFLICT",
status_code=409,
severity="unrecoverable",
)


class CheckoutNotModifiableError(UcpError):
"""Raised when attempting to modify a checkout in a terminal state."""

def __init__(self, message: str):
"""Initialize CheckoutNotModifiableError."""
super().__init__(message, code="CHECKOUT_NOT_MODIFIABLE", status_code=409)
super().__init__(
message,
code="CHECKOUT_NOT_MODIFIABLE",
status_code=409,
severity="unrecoverable",
)


class OutOfStockError(UcpError):
"""Raised when there is insufficient inventory for an item."""

def __init__(self, message: str, status_code: int = 400):
"""Initialize OutOfStockError."""
super().__init__(message, code="OUT_OF_STOCK", status_code=status_code)
super().__init__(
message,
code="OUT_OF_STOCK",
status_code=status_code,
severity="unrecoverable",
)


class PaymentFailedError(UcpError):
"""Raised when payment processing fails."""

def __init__(
self, message: str, code: str = "PAYMENT_FAILED", status_code: int = 402
self,
message: str,
code: str = "PAYMENT_FAILED",
status_code: int = 402,
):
"""Initialize PaymentFailedError."""
super().__init__(message, code=code, status_code=status_code)
super().__init__(
message,
code=code,
status_code=status_code,
severity="requires_buyer_input",
)


class InvalidRequestError(UcpError):
"""Raised when the request is invalid (e.g. missing fields)."""

def __init__(self, message: str):
"""Initialize InvalidRequestError."""
super().__init__(message, code="INVALID_REQUEST", status_code=400)
super().__init__(
message,
code="INVALID_REQUEST",
status_code=400,
severity="unrecoverable",
)


class UcpVersionError(UcpError):
"""Raised when a UCP version string is invalid or unsupported."""

def __init__(self, message: str, code: str = "VERSION_INVALID_FORMAT"):
def __init__(
self,
message: str,
code: str = "VERSION_INVALID_FORMAT",
status_code: int = 400,
severity: str = "unrecoverable",
):
"""Initialize UcpVersionError."""
super().__init__(message, code=code, status_code=400)

def to_detail(self) -> UcpErrorDetail:
"""Return an error payload matching UCP REST error shape."""
return UcpErrorDetail(
errors=[
UcpErrorDetailItem(
code=self.code,
message=self.message,
severity="critical",
)
]
super().__init__(
message, code=code, status_code=status_code, severity=severity
)
46 changes: 28 additions & 18 deletions rest/python/server/integration_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,9 @@ async def verify_inventory() -> int | None:
json=payload.model_dump(mode="json", exclude_none=True),
)
self.assertEqual(response.status_code, 400)
self.assertIn("Insufficient stock", response.json()["detail"])
data = response.json()
self.assertEqual(data["ucp"]["status"], "error")
self.assertIn("Insufficient stock", data["messages"][0]["content"])

def test_double_complete_checkout(self) -> None:
"""Test that completing a checkout twice is idempotent."""
Expand Down Expand Up @@ -372,8 +374,10 @@ def test_double_complete_checkout(self) -> None:
json=payment_payload,
)
self.assertEqual(response.status_code, 409)
data = response.json()
self.assertEqual(data["ucp"]["status"], "error")
self.assertEqual(
response.json()["detail"],
data["messages"][0]["content"],
"Cannot complete checkout in state 'completed'",
)

Expand Down Expand Up @@ -561,7 +565,9 @@ def test_cancel_checkout(self) -> None:
),
)
self.assertEqual(response.status_code, 409)
self.assertIn("Cannot cancel checkout", response.json()["detail"])
data = response.json()
self.assertEqual(data["ucp"]["status"], "error")
self.assertIn("Cannot cancel checkout", data["messages"][0]["content"])

# 4. Create another checkout and complete it, then try to cancel
payload = self._create_checkout_payload(
Expand Down Expand Up @@ -595,7 +601,9 @@ def test_cancel_checkout(self) -> None:
),
)
self.assertEqual(response.status_code, 409)
self.assertIn("Cannot cancel checkout", response.json()["detail"])
data = response.json()
self.assertEqual(data["ucp"]["status"], "error")
self.assertIn("Cannot cancel checkout", data["messages"][0]["content"])

def _notify_and_capture(
self, checkout: UnifiedCheckout, event_type: str
Expand Down Expand Up @@ -737,14 +745,15 @@ def test_version_invalid_format(self) -> None:
)
self.assertEqual(response.status_code, 400)

# Verify the error structure matches UcpErrorDetail
# Verify the error structure matches UcpErrorResponse
data = response.json()
self.assertIn("detail", data)
detail = data["detail"]
self.assertEqual(detail["status"], "error")
self.assertEqual(len(detail["errors"]), 1)
self.assertEqual(detail["errors"][0]["code"], "VERSION_INVALID_FORMAT")
self.assertEqual(detail["errors"][0]["severity"], "critical")
self.assertNotIn("detail", data)
self.assertEqual(data["ucp"]["status"], "error")
self.assertEqual(data["ucp"]["version"], app.version)
self.assertEqual(len(data["messages"]), 1)
self.assertEqual(data["messages"][0]["type"], "error")
self.assertEqual(data["messages"][0]["code"], "VERSION_INVALID_FORMAT")
self.assertEqual(data["messages"][0]["severity"], "unrecoverable")

def test_version_unsupported(self) -> None:
"""Tests that UCP-Agent with unsupported (newer) version is rejected."""
Expand All @@ -764,14 +773,15 @@ def test_version_unsupported(self) -> None:
)
self.assertEqual(response.status_code, 422)

# Verify the error structure matches UcpErrorDetail
# Verify the error structure matches UcpErrorResponse
data = response.json()
self.assertIn("detail", data)
detail = data["detail"]
self.assertEqual(detail["status"], "error")
self.assertEqual(len(detail["errors"]), 1)
self.assertEqual(detail["errors"][0]["code"], "VERSION_UNSUPPORTED")
self.assertEqual(detail["errors"][0]["severity"], "critical")
self.assertNotIn("detail", data)
self.assertEqual(data["ucp"]["status"], "error")
self.assertEqual(data["ucp"]["version"], app.version)
self.assertEqual(len(data["messages"]), 1)
self.assertEqual(data["messages"][0]["type"], "error")
self.assertEqual(data["messages"][0]["code"], "VERSION_UNSUPPORTED")
self.assertEqual(data["messages"][0]["severity"], "unrecoverable")


if __name__ == "__main__":
Expand Down
19 changes: 0 additions & 19 deletions rest/python/server/routes/discovery_profile.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,25 +9,6 @@
"transport": "rest",
"endpoint": "{{ENDPOINT}}",
"schema": "https://ucp.dev/2026-04-08/services/shopping/openapi.json"
},
{
"version": "2026-04-08",
"spec": "https://ucp.dev/2026-04-08/specification/overview",
"transport": "mcp",
"endpoint": "{{ENDPOINT}}/mcp",
"schema": "https://ucp.dev/2026-04-08/services/shopping/openrpc.json"
},
{
"version": "2026-04-08",
"spec": "https://ucp.dev/2026-04-08/specification/overview",
"transport": "a2a",
"endpoint": "{{ENDPOINT}}/.well-known/agent-card.json"
},
{
"version": "2026-04-08",
"spec": "https://ucp.dev/2026-04-08/specification/overview",
"transport": "embedded",
"schema": "https://ucp.dev/2026-04-08/services/shopping/embedded.json"
}
]
},
Expand Down
15 changes: 14 additions & 1 deletion rest/python/server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,20 @@ async def ucp_exception_handler(request: Request, exc: UcpError):
del request # Unused.
return JSONResponse(
status_code=exc.status_code,
content={"detail": exc.message, "code": exc.code},
content={
"ucp": {
"version": config.get_server_version(),
"status": "error",
},
"messages": [
{
"type": "error",
"code": exc.code,
"content": exc.message,
"severity": exc.severity,
}
],
},
)


Expand Down
Loading