Comparison API Edge-Case Contract Pack
Issue: BUY-2525 Goal: BUY-2037 Goal Status: Updated with Implementation Findings (Vera-unblocked)
Overview
This document defines the edge-case contract for BuyWhere comparison endpoints. AI agents depend on predictable behavior when retailer sets are empty, price coverage is partial, offers are stale, or pagination thresholds are hit.
Source code: app/routers/compare.py (lines 92-156), app/compare.py (ProductMatcher)
Affected Endpoints:
GET /v1/products/{product_id}/compare(price comparison by product ID)POST /v1/products/compare(matrix comparison)POST /v1/products/compare/diff(field diff comparison)GET /v2/agents/price-comparison(agent-native price comparison)POST /v2/agents/bulk-compare(agent-native bulk compare)POST /v2/agents/compare-matrix(agent-native compare matrix)
1. Endpoint Edge-Case Matrix
| Edge Case | Endpoint | Expected Behavior | Confirmed? |
|---|---|---|---|
| Empty retailer set (no matches found) | GET /v1/products/{id}/compare | Return 200 with matches: [], highlights: {cheapest: null, best_rated: null, fastest_shipping: null} | ✅ CONFIRMED |
| Partial price coverage (some retailers have no price) | All compare endpoints | Include retailer with price_missing: true and price_missing_reason | ⚠️ SCHEMA EXISTS, NOT POPULATED |
| Stale offers (data > 7 days old) | All compare endpoints | Include offer with data_freshness: "stale" flag | ⚠️ SCHEMA EXISTS, NOT POPULATED |
| Very stale offers (data > 30 days old) | All compare endpoints | Include offer with data_freshness: "very_stale" and is_available: false | ⚠️ SCHEMA EXISTS, NOT POPULATED |
| Pagination limit hit | All list-based compare endpoints | Return has_more: true + next_cursor | ⚠️ NOT FULLY IMPLEMENTED |
| Invalid product ID | All product-specific endpoints | Return 404 with "Product not found" | ✅ CONFIRMED |
| Rate limit hit | All endpoints | Return 429 with Retry-After header | ⚠️ NOT VERIFIED |
| Zero-price product | All compare endpoints | Include with zero_price: true flag | ⚠️ SCHEMA EXISTS, NOT POPULATED |
| Missing image | All compare endpoints | Return image_url: null | ✅ CONFIRMED |
| Retailer temporarily unavailable | All compare endpoints | Skip retailer; log to unavailable_retailers[] in meta | ❌ NOT IMPLEMENTED |
2. Response-Shape Examples (Implementation-Verified)
2.1 Empty Retailer Set (No Matches) ✅ CONFIRMED
Implementation at app/routers/compare.py:119-152:
source_product = source_result.scalar_one_or_none()
if not source_product:
raise HTTPException(status_code=404, detail="Product not found")
matcher = ProductMatcher(db)
matches_with_scores = await matcher.find_matches(source_product, min_price, max_price)
matches: List[CompareMatch] = []
for matched_product, score in matches_with_scores:
matches.append(_build_compare_match(matched_product, score, campaign_slug=campaign_slug))
matches.sort(key=lambda x: x.price)
highlights = _get_highlights(matches)
response = CompareResponse(
source_product_id=source_product.id,
source_product_name=source_product.title,
matches=matches,
total_matches=len(matches), # Will be 0
highlights=highlights,
)
highlights when no matches (_get_highlights at line 61-63):
def _get_highlights(matches: List[CompareMatch]) -> CompareHighlights:
if not matches:
return CompareHighlights(cheapest=None, best_rated=None, fastest_shipping=None)
Actual Response (200 OK):
{
"source_product_id": 99999999,
"source_product_name": "Some Unknown Product",
"total_matches": 0,
"matches": [],
"highlights": {
"cheapest": null,
"best_rated": null,
"fastest_shipping": null
}
}
Contract Rule: When total_matches === 0, return empty array. highlights is an object with all null fields (NOT null itself).
2.2 Partial Price Coverage ⚠️ SCHEMA EXISTS, NOT POPULATED
Schema (app/schemas/product.py:259-260):
price_missing: bool = Field(False, description="True when price is not available from this retailer")
price_missing_reason: Optional[PriceMissingReason] = Field(None, description="Reason why price is missing")
PriceMissingReason enum (line 336-341):
class PriceMissingReason(str, Enum):
RETAILER_API_ERROR = "retailer_api_error"
RETAILER_TIMEOUT = "retailer_timeout"
PRODUCT_UNAVAILABLE = "product_unavailable"
PRICE_NOT_DISCLOSED = "price_not_disclosed"
CRAWL_ERROR = "crawl_error"
BUG FOUND at app/routers/compare.py:65:
cheapest = min(matches, key=lambda x: x.price, default=None)
This will raise TypeError if any match has price=None (from a missing price scenario). The min() function cannot compare Decimal with NoneType.
Expected behavior (when implemented):
{
"source_product_id": 18472931,
"source_product_name": "ASUS VivoBook 15 Laptop",
"total_matches": 3,
"matches": [
{
"id": 18472931,
"source": "shopee_sg",
"name": "ASUS VivoBook 15 Laptop",
"price": 1299.00,
"currency": "SGD",
"is_available": true,
"last_checked": "2026-04-16T10:00:00Z",
"data_freshness": "fresh",
"price_missing": false,
"price_missing_reason": null,
"zero_price": false
},
{
"id": 18472932,
"source": "lazada_sg",
"name": "ASUS VivoBook 15 Laptop",
"price": null,
"currency": "SGD",
"is_available": false,
"last_checked": "2026-04-16T11:30:00Z",
"data_freshness": "recent",
"price_missing": true,
"price_missing_reason": "retailer_api_error",
"zero_price": false
}
],
"highlights": {
"cheapest": { "id": 18472931, "source": "shopee_sg", "price": 1299.00 }
}
}
Contract Rule: When a retailer has no price, include the match with price_missing: true and price_missing_reason. The _get_highlights function must handle None prices without raising TypeError.
2.3 Stale Offers ⚠️ SCHEMA EXISTS, NOT POPULATED
Schema (app/schemas/product.py:262):
data_freshness: Optional[str] = Field(None, description="Data freshness tier: fresh (<24h), recent (24h-7d), stale (7-30d), very_stale (>30d)")
Implementation status: Field exists in schema but _build_compare_match (line 36-58) does not populate it.
Expected Freshness Tier Definitions:
| Tier | Duration | data_freshness | is_available behavior |
|---|---|---|---|
| Fresh | < 24 hours | "fresh" | Trust value |
| Recent | 24h - 7 days | "recent" | Trust value |
| Stale | 7 - 30 days | "stale" | Trust but flag warning |
| Very Stale | > 30 days | "very_stale" | Set is_available: false |
Expected Response (when implemented):
{
"matches": [
{
"id": 18472931,
"source": "shopee_sg",
"price": 1299.00,
"is_available": true,
"last_checked": "2026-04-09T10:00:00Z",
"data_freshness": "stale"
},
{
"id": 18472933,
"source": "amazon_sg",
"price": 1349.00,
"is_available": false,
"last_checked": "2026-04-01T08:00:00Z",
"data_freshness": "very_stale"
}
]
}
2.4 Pagination ⚠️ PARTIALLY IMPLEMENTED
Agent native response (app/schemas/agent/compare.py:50-62):
class AgentPriceComparisonResponse(BaseModel):
has_more: bool
next_cursor: Optional[str] = Field(None, description="Cursor for pagination (base64-encoded integer offset)")
compare_product_by_id (v1) does NOT implement pagination — it returns all matches.
2.5 Error: Invalid Product ID ✅ CONFIRMED
Implementation at app/routers/compare.py:122-124:
source_product = source_result.scalar_one_or_none()
if not source_product:
raise HTTPException(status_code=404, detail="Product not found")
Actual Response (404 Not Found):
{
"detail": "Product not found"
}
3. Contract Ambiguities — Updated Implementation Status
| # | Ambiguity | Current Behavior | Proposed Contract | Status |
|---|---|---|---|---|
| 1 | Empty result vs error for no matches | Returns 200 with empty matches: [] and highlights with null fields | Confirmed correct | ✅ RESOLVED |
| 2 | last_checked null handling | Sometimes null, sometimes missing | Always present; null = "never checked" | ⚠️ UNCHANGED |
| 3 | price: 0 interpretation | Schema has zero_price flag, not populated | Add zero_price: true for free items | ⚠️ SCHEMA EXISTS |
| 4 | highlights null when no matches | Returns CompareHighlights(cheapest=None, ...) (object with nulls, NOT null) | Confirmed correct | ✅ RESOLVED |
| 5 | Retailer API timeout behavior | price_missing_reason: "retailer_timeout" in schema, not populated | Set reason on timeout | ⚠️ SCHEMA EXISTS |
| 6 | data_freshness calculation | Schema has field, not populated | Implement tier classification | ⚠️ SCHEMA EXISTS |
| 7 | Pagination cursor TTL | Not documented | Cursors expire after 30 min | ⚠️ NOT IMPLEMENTED |
| 8 | Partial failure in batch compare | Not tracked | Add failed_product_ids[] | ⚠️ NOT IMPLEMENTED |
| 9 | is_available vs availability_prediction | Confusing overlap | is_available = confirmed; availability_prediction = ML | ⚠️ NOT CLARIFIED |
| 10 | Cross-border price conversion | Not verified | target_currency converts all prices | ⚠️ NOT VERIFIED |
| 11 | BUG: None price in min() | _get_highlights uses min(matches, key=lambda x: x.price) which fails if price is None | Filter or handle None prices | ❌ BUG |
4. Bugs Found in Current Implementation
BUG-1: TypeError when comparing matches with None price
Location: app/routers/compare.py:65
cheapest = min(matches, key=lambda x: x.price, default=None)
Problem: If a CompareMatch has price=None (when price_missing=True), this raises:
TypeError: '<=' not supported between instances of 'NoneType' and 'Decimal'
Fix Required: Filter out or handle None prices when computing cheapest:
available_matches = [m for m in matches if m.price is not None]
cheapest = min(available_matches, key=lambda x: x.price, default=None) if available_matches else None
5. Backend Resolution Required
HIGH Priority (Bugs/Blocking Issues)
- BUG-1 Fix: Handle
Noneprices in_get_highlights✅ FIXED - Empty result behavior: Confirmed ✅
- Retailer timeout SLA: Define timeout value and implement
price_missing_reason
MEDIUM Priority (Schema Exists, Not Populated)
- Stale/very_stale classification: Implement
data_freshnesscalculation - Zero-price handling: Populate
zero_pricefield - Partial batch failure: Implement
failed_product_ids[]tracking - Price conversion: Verify
target_currencybehavior
LOW Priority (Documentation/Polish)
- Pagination cursor TTL: Document or implement expiration
- Availability fields: Clarify semantics
- Retailer unavailable tracking: Implement
unavailable_retailers[]in meta
6. Implementation Checklist
- Document confirmed edge-case behaviors (empty set, 404, highlights null handling)
- Fix BUG-1: Handle None prices in
_get_highlights(see comparison-sparse-state.md) - Populate
price_missingandprice_missing_reasonfields - Implement
data_freshnesstier classification - Populate
zero_priceflag for free items - Implement
failed_product_ids[]for batch operations - Verify
target_currencyprice conversion - Implement
unavailable_retailers[]in meta - Document pagination cursor TTL
- Update SDK with edge-case field documentation
- Create sparse-state and low-coverage UI documentation (see comparison-sparse-state.md)
Appendix: Source Code References
| Component | Location | Key Lines |
|---|---|---|
| Compare endpoint | app/routers/compare.py | 92-156 |
_get_highlights | app/routers/compare.py | 61-89 |
_build_compare_match | app/routers/compare.py | 36-58 |
ProductMatcher | app/compare.py | 83-164 |
CompareMatch schema | app/schemas/product.py | 236-262 / 344-370 |
CompareResponse schema | app/schemas/product.py | 271-277 |
PriceMissingReason enum | app/schemas/product.py | 336-341 |
| Agent compare schemas | app/schemas/agent/compare.py | 8-64 |
Document version: 1.1
Updated with implementation findings from actual source code
Source: app/routers/compare.py, app/compare.py, app/schemas/product.py