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:
2026-09-30 11:48:27 +05:30
parent fb859ecda1
commit c49f5372a5
4 changed files with 222 additions and 16 deletions

View File

@@ -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)
}
}