Skip to content
Merged
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
17 changes: 17 additions & 0 deletions rest/python/server/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,20 @@ class OrderStatus(str, enum.Enum):
"""Possible states for an order."""

PROCESSING = "processing"


class MessageType(str, enum.Enum):
"""Type of message in UCP response."""

ERROR = "error"
WARNING = "warning"
INFO = "info"


class ErrorSeverity(str, enum.Enum):
"""Severity levels for UCP error messages."""

RECOVERABLE = "recoverable"
REQUIRES_BUYER_INPUT = "requires_buyer_input"
REQUIRES_BUYER_REVIEW = "requires_buyer_review"
UNRECOVERABLE = "unrecoverable"
95 changes: 66 additions & 29 deletions rest/python/server/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,32 +16,40 @@

from pydantic import BaseModel

from enums import ErrorSeverity, MessageType

class UcpErrorDetailItem(BaseModel):
"""Details of a single error."""

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

type: MessageType = MessageType.ERROR
code: str
message: str
severity: str = "critical"
content: str
severity: ErrorSeverity


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",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

enums?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

added, thanks

status_code: int = 500,
severity: ErrorSeverity = ErrorSeverity.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 +58,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=ErrorSeverity.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=ErrorSeverity.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=ErrorSeverity.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=ErrorSeverity.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=ErrorSeverity.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=ErrorSeverity.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: ErrorSeverity = ErrorSeverity.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
)
67 changes: 49 additions & 18 deletions rest/python/server/integration_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
import dependencies
from fastapi.testclient import TestClient
import respx
from enums import ErrorSeverity, MessageType
from exceptions import UcpErrorResponse, UcpMessageError
from models import UnifiedCheckout
from server.server import app
from services.checkout_service import CheckoutService
Expand Down Expand Up @@ -340,7 +342,10 @@ 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.assertEqual(len(data["messages"]), 1)
self.assertIn("Insufficient stock", data["messages"][0]["content"])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

what if messages is empty?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

fixed


def test_double_complete_checkout(self) -> None:
"""Test that completing a checkout twice is idempotent."""
Expand Down Expand Up @@ -372,8 +377,11 @@ 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(len(data["messages"]), 1)
self.assertEqual(
response.json()["detail"],
data["messages"][0]["content"],
"Cannot complete checkout in state 'completed'",
)

Expand Down Expand Up @@ -561,7 +569,10 @@ 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.assertEqual(len(data["messages"]), 1)
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 +606,10 @@ 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.assertEqual(len(data["messages"]), 1)
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 +751,21 @@ 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

why don't you just assert that it equals an UcpErrorResponse object? wouldn't it be a lot cleaner?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

done!

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)
expected = UcpErrorResponse(
ucp={"version": app.version, "status": "error"},
messages=[
UcpMessageError(
type=MessageType.ERROR,
code="VERSION_INVALID_FORMAT",
content=("Version 'bad-version' is invalid. Expected YYYY-MM-DD."),
severity=ErrorSeverity.UNRECOVERABLE,
)
],
)
self.assertEqual(UcpErrorResponse.model_validate(data), expected)

def test_version_unsupported(self) -> None:
"""Tests that UCP-Agent with unsupported (newer) version is rejected."""
Expand All @@ -764,14 +785,24 @@ 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)
expected = UcpErrorResponse(
ucp={"version": app.version, "status": "error"},
messages=[
UcpMessageError(
type=MessageType.ERROR,
code="VERSION_UNSUPPORTED",
content=(
f"Version 2026-04-09 is not supported. This merchant"
f" implements version {app.version}."
),
severity=ErrorSeverity.UNRECOVERABLE,
)
],
)
self.assertEqual(UcpErrorResponse.model_validate(data), expected)


if __name__ == "__main__":
Expand Down
Loading
Loading