nutrition: do not cache a lookup made under an unresolved brand
Brand case decides whether the catalogue-intelligence service answers at all.
Measured 30 Sep 2026:
/nutrition/Balaji/balaji_..._135g -> health_score 65.3, 545 kcal
/nutrition/balaji/... (our spelling) -> every field null
The brand list resolves ours to theirs, and /brands has slowed to 0.2-2.3s,
which exceeded the 3s client timeout on a cold start. The fallback then asked
under our own spelling, received a well-formed empty record, and cached it as
"no nutrition" for six hours -- so one slow moment silently removed nutrition
and health scores from every product of every brand, looking exactly like data
the agent team had not supplied.
Two changes:
- a result reached without a resolved brand is no longer cached, so the next
request retries rather than inheriting a wrong answer for six hours. A
genuine miss on a resolved brand is still cached, which is the case that
matters for traffic.
- the brand list is warmed in the background at startup, so no shopper is
ever in the path of that call.
Also logs which state the feature is in at boot, the way mail does. With
NUTRITION_BASE unset the endpoint simply omits `nutrition` and `healthscore`,
which is indistinguishable from an unscored product -- this deploy went out
without the variable set and had to be diagnosed by probing the API from
outside.
scratch/nutritionlive prints the exact response for any product by running this
code against the live product row and the live service.
NUTRITION_BASE=https://mcp.nearle.ai.in/api must be set in the deployment
environment. Unset, nothing changes and no product carries either key.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"nearle/models"
|
||||
@@ -369,3 +370,80 @@ func TestAProductWithNoNutritionHasNoKeyAtAll(t *testing.T) {
|
||||
t.Fatalf("an absent panel was sent as a key: %s", encoded)
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Not caching an answer we do not trust ───────────────────────────────── */
|
||||
|
||||
func TestAnUnresolvedBrandIsNotCachedAsAMiss(t *testing.T) {
|
||||
// The failure this prevents, measured 30 Sep 2026:
|
||||
// `/nutrition/Balaji/balaji_..._135g` returns a 65.3 score and 545 kcal;
|
||||
// `/nutrition/balaji/...` — our own spelling — returns a well-formed record
|
||||
// with every field null. So a lookup made before the brand list arrived
|
||||
// does not mean "no nutrition", it means we asked the wrong question.
|
||||
//
|
||||
// Cached, one slow moment on /brands would silently remove nutrition from
|
||||
// every product of every brand for six hours.
|
||||
var brandsUp bool
|
||||
var productHits int
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/brands" {
|
||||
if !brandsUp {
|
||||
w.WriteHeader(http.StatusGatewayTimeout)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"brands":["Balaji"]}`))
|
||||
return
|
||||
}
|
||||
productHits++
|
||||
if strings.Contains(r.URL.Path, "/Balaji/") {
|
||||
_, _ = w.Write([]byte(balajiJSON))
|
||||
return
|
||||
}
|
||||
// What the service actually answers for the wrong spelling.
|
||||
_, _ = w.Write([]byte(unavailableJSON))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
service := NewNutritionService(server.URL)
|
||||
|
||||
// Brand list down: the answer is a miss, and must not stick.
|
||||
if found := service.ForProduct("balaji", "balaji_wafers_135g"); found.Panel != nil {
|
||||
t.Fatal("got a panel from the wrong spelling")
|
||||
}
|
||||
|
||||
// Brand list back: the same product must be asked again, correctly.
|
||||
brandsUp = true
|
||||
found := service.ForProduct("balaji", "balaji_wafers_135g")
|
||||
if !found.Panel.HasValues() {
|
||||
t.Fatal("the miss was cached — a slow /brands has cost six hours of nutrition")
|
||||
}
|
||||
if found.Health == nil || found.Health.Score != 65 {
|
||||
t.Fatalf("score lost: %+v", found.Health)
|
||||
}
|
||||
if productHits < 2 {
|
||||
t.Fatalf("the product was only asked for %d time(s)", productHits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAResolvedMissIsStillCached(t *testing.T) {
|
||||
// The other half: when the brand IS theirs and they simply have nothing,
|
||||
// that is a real answer and re-asking on every tap wastes the most
|
||||
// requests on the least useful result.
|
||||
var productHits int
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/brands" {
|
||||
_, _ = w.Write([]byte(`{"brands":["Patanjali"]}`))
|
||||
return
|
||||
}
|
||||
productHits++
|
||||
_, _ = w.Write([]byte(unavailableJSON))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
service := NewNutritionService(server.URL)
|
||||
for i := 0; i < 4; i++ {
|
||||
service.ForProduct("patanjali", "patanjali_cow_ghee_500ml")
|
||||
}
|
||||
if productHits != 1 {
|
||||
t.Fatalf("asked %d times for a product the service has answered about", productHits)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user