← Back to documentation

comparison-edge-cases

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 CaseEndpointExpected BehaviorConfirmed?
Empty retailer set (no matches found)GET /v1/products/{id}/compareReturn 200 with matches: [], highlights: {cheapest: null, best_rated: null, fastest_shipping: null}✅ CONFIRMED
Partial price coverage (some retailers have no price)All compare endpointsInclude retailer with price_missing: true and price_missing_reason⚠️ SCHEMA EXISTS, NOT POPULATED
Stale offers (data > 7 days old)All compare endpointsInclude offer with data_freshness: "stale" flag⚠️ SCHEMA EXISTS, NOT POPULATED
Very stale offers (data > 30 days old)All compare endpointsInclude offer with data_freshness: "very_stale" and is_available: false⚠️ SCHEMA EXISTS, NOT POPULATED
Pagination limit hitAll list-based compare endpointsReturn has_more: true + next_cursor⚠️ NOT FULLY IMPLEMENTED
Invalid product IDAll product-specific endpointsReturn 404 with "Product not found"✅ CONFIRMED
Rate limit hitAll endpointsReturn 429 with Retry-After header⚠️ NOT VERIFIED
Zero-price productAll compare endpointsInclude with zero_price: true flag⚠️ SCHEMA EXISTS, NOT POPULATED
Missing imageAll compare endpointsReturn image_url: null✅ CONFIRMED
Retailer temporarily unavailableAll compare endpointsSkip 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:

TierDurationdata_freshnessis_available behavior
Fresh< 24 hours"fresh"Trust value
Recent24h - 7 days"recent"Trust value
Stale7 - 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

#AmbiguityCurrent BehaviorProposed ContractStatus
1Empty result vs error for no matchesReturns 200 with empty matches: [] and highlights with null fieldsConfirmed correct✅ RESOLVED
2last_checked null handlingSometimes null, sometimes missingAlways present; null = "never checked"⚠️ UNCHANGED
3price: 0 interpretationSchema has zero_price flag, not populatedAdd zero_price: true for free items⚠️ SCHEMA EXISTS
4highlights null when no matchesReturns CompareHighlights(cheapest=None, ...) (object with nulls, NOT null)Confirmed correct✅ RESOLVED
5Retailer API timeout behaviorprice_missing_reason: "retailer_timeout" in schema, not populatedSet reason on timeout⚠️ SCHEMA EXISTS
6data_freshness calculationSchema has field, not populatedImplement tier classification⚠️ SCHEMA EXISTS
7Pagination cursor TTLNot documentedCursors expire after 30 min⚠️ NOT IMPLEMENTED
8Partial failure in batch compareNot trackedAdd failed_product_ids[]⚠️ NOT IMPLEMENTED
9is_available vs availability_predictionConfusing overlapis_available = confirmed; availability_prediction = ML⚠️ NOT CLARIFIED
10Cross-border price conversionNot verifiedtarget_currency converts all prices⚠️ NOT VERIFIED
11BUG: None price in min()_get_highlights uses min(matches, key=lambda x: x.price) which fails if price is NoneFilter 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)

  1. BUG-1 Fix: Handle None prices in _get_highlights ✅ FIXED
  2. Empty result behavior: Confirmed ✅
  3. Retailer timeout SLA: Define timeout value and implement price_missing_reason

MEDIUM Priority (Schema Exists, Not Populated)

  1. Stale/very_stale classification: Implement data_freshness calculation
  2. Zero-price handling: Populate zero_price field
  3. Partial batch failure: Implement failed_product_ids[] tracking
  4. Price conversion: Verify target_currency behavior

LOW Priority (Documentation/Polish)

  1. Pagination cursor TTL: Document or implement expiration
  2. Availability fields: Clarify semantics
  3. 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_missing and price_missing_reason fields
  • Implement data_freshness tier classification
  • Populate zero_price flag for free items
  • Implement failed_product_ids[] for batch operations
  • Verify target_currency price 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

ComponentLocationKey Lines
Compare endpointapp/routers/compare.py92-156
_get_highlightsapp/routers/compare.py61-89
_build_compare_matchapp/routers/compare.py36-58
ProductMatcherapp/compare.py83-164
CompareMatch schemaapp/schemas/product.py236-262 / 344-370
CompareResponse schemaapp/schemas/product.py271-277
PriceMissingReason enumapp/schemas/product.py336-341
Agent compare schemasapp/schemas/agent/compare.py8-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