diff --git a/backend/app/main.py b/backend/app/main.py index 77ad635..2a13e50 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -107,6 +107,7 @@ validate_origin_login_handoff, ) from .services.open_food_facts import search_food_products +from .services.food_search_availability import FoodSearchUnavailable logger = logging.getLogger(__name__) @@ -1690,6 +1691,21 @@ async def search_food(q: str = Query(..., min_length=1, max_length=120)) -> Food try: results = await search_food_products(query) + except FoodSearchUnavailable as exc: + logger.warning("Open Food Facts unavailable (status=%s)", exc.status_code) + raise HTTPException( + status_code=exc.status_code, + detail=( + "Food search rate limit reached" + if exc.status_code == 429 + else "Food search temporarily unavailable" + ), + headers={ + "Retry-After": str(exc.retry_after_seconds), + "Cache-Control": "no-store", + "Pragma": "no-cache", + }, + ) from exc except AdapterAdmissionRejected as exc: logger.warning( "Open Food Facts admission rejected (reason=%s)", diff --git a/backend/app/services/food_search_availability.py b/backend/app/services/food_search_availability.py new file mode 100644 index 0000000..399bcdb --- /dev/null +++ b/backend/app/services/food_search_availability.py @@ -0,0 +1,115 @@ +"""Short-lived public search results and provider-directed cooldowns.""" + +import hashlib +import math +import time +from collections import OrderedDict +from collections.abc import Callable +from datetime import UTC, datetime +from email.utils import parsedate_to_datetime +from threading import Lock + +from app.schemas import FoodSearchResult + + +DEFAULT_PROVIDER_RETRY_SECONDS = 30 + + +def provider_retry_seconds(value: str | None) -> int: + """Honor Retry-After without treating a missing header as immediate retry.""" + if value: + value = value.strip() + if value.isascii() and value.isdigit() and len(value) <= 10: + return max(1, int(value)) + try: + retry_at = parsedate_to_datetime(value) + if retry_at.tzinfo is None: + retry_at = retry_at.replace(tzinfo=UTC) + seconds = math.ceil((retry_at - datetime.now(UTC)).total_seconds()) + if seconds > 0: + return seconds + except (TypeError, ValueError, OverflowError): + pass + return DEFAULT_PROVIDER_RETRY_SECONDS + + +class FoodSearchUnavailable(Exception): + """A provider status safe to expose without request text or response bodies.""" + + def __init__(self, status_code: int, retry_after_seconds: int) -> None: + super().__init__("Food search temporarily unavailable") + self.status_code = status_code + self.retry_after_seconds = retry_after_seconds + + +class FoodSearchAvailability: + def __init__( + self, + *, + max_entries: int = 64, + ttl_seconds: float = 300, + clock: Callable[[], float] = time.monotonic, + ) -> None: + self.max_entries = max_entries + self.ttl_seconds = ttl_seconds + self.clock = clock + self._lock = Lock() + self._cache: OrderedDict[bytes, tuple[float, list[FoodSearchResult]]] = OrderedDict() + self._retry_at = 0.0 + self._status_code = 503 + + @staticmethod + def _key(query: str, page_size: int) -> bytes: + return hashlib.sha256(f"{page_size}\0{query}".encode("utf-8")).digest() + + def _expire(self, now: float) -> None: + for key, (expires_at, _) in list(self._cache.items()): + if expires_at <= now: + del self._cache[key] + + def get(self, query: str, page_size: int) -> list[FoodSearchResult] | None: + with self._lock: + self._expire(self.clock()) + key = self._key(query, page_size) + entry = self._cache.get(key) + if entry is None: + return None + self._cache.move_to_end(key) + return [item.model_copy(deep=True) for item in entry[1]] + + def remember(self, query: str, page_size: int, results: list[FoodSearchResult]) -> None: + # Do not turn a transient empty provider response into a cached absence. + if not results: + return + with self._lock: + now = self.clock() + self._expire(now) + key = self._key(query, page_size) + self._cache[key] = ( + now + self.ttl_seconds, + [item.model_copy(deep=True) for item in results], + ) + self._cache.move_to_end(key) + while len(self._cache) > self.max_entries: + self._cache.popitem(last=False) + + def check_provider(self) -> None: + with self._lock: + remaining = math.ceil(self._retry_at - self.clock()) + if remaining > 0: + raise FoodSearchUnavailable(self._status_code, remaining) + + def pause_provider(self, status_code: int, seconds: int) -> None: + with self._lock: + retry_at = self.clock() + seconds + # An overlapping request must never shorten a provider's pause. + if retry_at >= self._retry_at: + self._retry_at = retry_at + self._status_code = status_code + + def reset(self) -> None: + """Clear process-local state for isolated tests.""" + with self._lock: + self._cache.clear() + self._retry_at = 0.0 + self._status_code = 503 diff --git a/backend/app/services/open_food_facts.py b/backend/app/services/open_food_facts.py index 0f803fc..0124cf7 100644 --- a/backend/app/services/open_food_facts.py +++ b/backend/app/services/open_food_facts.py @@ -3,6 +3,7 @@ import json import logging import math +import re import shutil import subprocess from collections.abc import Awaitable, Callable @@ -17,6 +18,11 @@ from app.database import engine from app.provider_rate_governor import build_provider_rate_governor from app.schemas import FoodSearchResult +from app.services.food_search_availability import ( + FoodSearchAvailability, + FoodSearchUnavailable, + provider_retry_seconds, +) from app.source_admission import ( AdapterAdmissionController, AdapterAdmissionRejected, @@ -55,11 +61,15 @@ DuplicateRequestCoalescer() ) _OPEN_FOOD_FACTS_RATE_GOVERNOR = build_provider_rate_governor(engine) +_OPEN_FOOD_FACTS_AVAILABILITY = FoodSearchAvailability() async def _governed_attempt(operation: Callable[[], Awaitable[T]]) -> T: """Reserve shared egress capacity immediately before an upstream attempt.""" + _OPEN_FOOD_FACTS_AVAILABILITY.check_provider() await _OPEN_FOOD_FACTS_RATE_GOVERNOR.acquire() + # Another request can start a provider pause while this reservation waits. + _OPEN_FOOD_FACTS_AVAILABILITY.check_provider() return await operation() @@ -133,6 +143,10 @@ def _extract_nutri_score(product: dict[str, Any]) -> str | None: async def search_food_products(query: str, page_size: int = 10) -> list[FoodSearchResult]: safe_query = query.strip() + cached = _OPEN_FOOD_FACTS_AVAILABILITY.get(safe_query, page_size) + if cached is not None: + return cached + _OPEN_FOOD_FACTS_AVAILABILITY.check_provider() return await _OPEN_FOOD_FACTS_COALESCER.run( (safe_query, page_size), lambda: _search_food_products_once(safe_query, page_size), @@ -182,6 +196,13 @@ async def _search_food_products_once( ) from fallback_exc results = _normalize_products(payload) + except httpx.HTTPStatusError as exc: + _OPEN_FOOD_FACTS_ADMISSION.record_failure(permit) + if exc.response.status_code in {429, 503}: + seconds = provider_retry_seconds(exc.response.headers.get("Retry-After")) + _OPEN_FOOD_FACTS_AVAILABILITY.pause_provider(exc.response.status_code, seconds) + raise FoodSearchUnavailable(exc.response.status_code, seconds) from exc + raise except AdapterAdmissionRejected: if permit.half_open_probe: _OPEN_FOOD_FACTS_ADMISSION.record_failure(permit) @@ -195,6 +216,7 @@ async def _search_food_products_once( raise else: _OPEN_FOOD_FACTS_ADMISSION.record_success(permit) + _OPEN_FOOD_FACTS_AVAILABILITY.remember(safe_query, page_size, results) return results @@ -273,6 +295,10 @@ def _curl_fetch(params: dict[str, Any]) -> dict[str, Any]: "--silent", "--show-error", "--fail", + "--dump-header", + "-", + "--write-out", + "\n%{http_code}", "-L", "--connect-timeout", "5", @@ -284,7 +310,7 @@ def _curl_fetch(params: dict[str, Any]) -> dict[str, Any]: "Accept: application/json", url, ], - check=True, + check=False, capture_output=True, timeout=15, ) @@ -292,16 +318,33 @@ def _curl_fetch(params: dict[str, Any]) -> dict[str, Any]: raise ValueError(f"{curl_cmd} not found on system; please ensure curl is installed") from exc except subprocess.TimeoutExpired as exc: raise ValueError("curl request timed out") from exc - except subprocess.CalledProcessError as exc: - stderr_text = (exc.stderr or b"").decode("utf-8", errors="replace").strip() - if stderr_text: - raise ValueError( - f"curl command failed (exit {exc.returncode}): {stderr_text}" - ) from exc - raise ValueError(f"curl command failed (exit {exc.returncode})") from exc - + response_bytes, separator, status_bytes = completed.stdout.rpartition(b"\n") + status_code = int(status_bytes) if separator and re.fullmatch(rb"\d{3}", status_bytes) else None + if completed.returncode != 0 and (status_code is None or status_code < 400): + # stderr can contain the private search URL. Keep only the diagnostic + # exit code, and preserve known HTTP errors for provider pause handling. + logger.warning("Open Food Facts curl transport failed (exit=%s)", completed.returncode) + raise ValueError(f"curl transport failed (exit {completed.returncode})") + if status_code is None: + raise ValueError("curl response is missing its HTTP status") + response_headers: dict[str, str] = {} + # curl can print CONNECT and redirect headers before the final response. + # Keep only the final headers, and keep all transfer data in memory. + while response_bytes.startswith(b"HTTP/"): + parts = re.split(rb"\r?\n\r?\n", response_bytes, maxsplit=1) + if len(parts) != 2: + raise ValueError("curl response headers are incomplete") + header_block, response_bytes = parts + response_headers = {} + for line in header_block.splitlines()[1:]: + key, colon, value = line.partition(b":") + if colon and key.lower() == b"retry-after": + response_headers["Retry-After"] = value.decode("ascii", errors="replace").strip() + if status_code >= 400: + _raise_fallback_http_status(status_code, response_headers.get("Retry-After")) + # Decode subprocess bytes explicitly to avoid Windows locale mojibake. - response_text = completed.stdout.decode("utf-8", errors="replace") + response_text = response_bytes.decode("utf-8", errors="replace") if not response_text.strip(): raise ValueError("curl returned empty response") try: @@ -325,6 +368,18 @@ def _resolve_curl_command() -> str | None: return shutil.which("curl") +def _raise_fallback_http_status(status_code: int, retry_after: str | None) -> None: + # Preserve status and Retry-After through the same handler as httpx, without + # attaching private query text or copying the provider's response body. + request = httpx.Request("GET", OPEN_FOOD_FACTS_SEARCH_URL) + response = httpx.Response( + status_code, + request=request, + headers={"Retry-After": retry_after} if retry_after else {}, + ) + response.raise_for_status() + + def _urllib_fetch(params: dict[str, Any]) -> dict[str, Any]: """Portable fallback using Python stdlib only (no external binaries required).""" query_string = urlencode(params) @@ -334,7 +389,10 @@ def _urllib_fetch(params: dict[str, Any]) -> dict[str, Any]: with urlopen(request, timeout=15) as response: response_bytes = response.read() except UrllibHTTPError as exc: - raise ValueError(f"urllib request failed with HTTP {exc.code}") from exc + _raise_fallback_http_status( + exc.code, exc.headers.get("Retry-After") if exc.headers else None + ) + raise ValueError("urllib returned an unexpected status") from exc except (URLError, TimeoutError) as exc: raise ValueError(f"urllib request failed: {exc}") from exc diff --git a/backend/tests/test_endpoints.py b/backend/tests/test_endpoints.py index 826fa33..88f79ac 100644 --- a/backend/tests/test_endpoints.py +++ b/backend/tests/test_endpoints.py @@ -220,6 +220,25 @@ def test_search_food_upstream_failure_returns_502(mock_search: AsyncMock, client assert response.status_code == 502 +@patch("app.main.search_food_products", new_callable=AsyncMock) +@pytest.mark.parametrize("status", [429, 503]) +def test_search_food_preserves_provider_pause_without_exposing_request_text( + mock_search: AsyncMock, client: TestClient, status: int, caplog: pytest.LogCaptureFixture, +) -> None: + from app.services.food_search_availability import FoodSearchUnavailable + + mock_search.side_effect = FoodSearchUnavailable(status, 120) + response = client.get("/search-food?q=private-search-term") + assert response.status_code == status + assert response.headers["retry-after"] == "120" + assert response.headers["cache-control"] == "no-store" + assert response.headers["pragma"] == "no-cache" + assert "private-search-term" not in response.text + messages = [record.getMessage() for record in caplog.records if record.name == "app.main"] + assert messages + assert all("private-search-term" not in message for message in messages) + + @patch("app.main.search_food_products", new_callable=AsyncMock) def test_search_food_admission_rejection_returns_bounded_503( mock_search: AsyncMock, diff --git a/backend/tests/test_food_search_availability.py b/backend/tests/test_food_search_availability.py new file mode 100644 index 0000000..93139c0 --- /dev/null +++ b/backend/tests/test_food_search_availability.py @@ -0,0 +1,56 @@ +from datetime import UTC, datetime, timedelta +from email.utils import format_datetime + +import pytest + +from app.schemas import FoodSearchResult +from app.services.food_search_availability import ( + FoodSearchAvailability, + FoodSearchUnavailable, + provider_retry_seconds, +) + + +def food(name: str) -> list[FoodSearchResult]: + return [FoodSearchResult(product_name=name, calories=100, protein=5, fat=2, carbohydrates=20)] + + +def test_cache_is_bounded_and_keeps_queries_and_page_sizes_separate() -> None: + state = FoodSearchAvailability(max_entries=2) + original = food("Oats") + state.remember("oats", 10, original) + original[0].product_name = "changed" + state.remember("oats", 20, food("More oats")) + assert state.get("oats", 10)[0].product_name == "Oats" + state.remember("apple", 10, food("Apple")) + assert state.get("oats", 20) is None # least recently used + assert state.get("oats", 10)[0].product_name == "Oats" + assert state.get("apple", 10)[0].product_name == "Apple" + assert state.get("apple", 20) is None + + +def test_overlapping_failure_cannot_shorten_provider_cooldown() -> None: + now = [0.0] + state = FoodSearchAvailability(clock=lambda: now[0]) + state.pause_provider(429, 120) + now[0] = 10 + state.pause_provider(503, 30) + with pytest.raises(FoodSearchUnavailable) as error: + state.check_provider() + assert error.value.status_code == 429 + assert error.value.retry_after_seconds == 110 + now[0] = 120 + state.check_provider() + + +@pytest.mark.parametrize("value", [None, "", "invalid", "-1", "NaN", "Infinity", "\r\nbad"]) +def test_missing_or_invalid_retry_after_uses_a_safe_default(value: str | None) -> None: + assert provider_retry_seconds(value) == 30 + + +def test_retry_after_preserves_long_pauses_and_accepts_http_dates() -> None: + assert provider_retry_seconds(" 7200 ") == 7200 + future = format_datetime(datetime.now(UTC) + timedelta(seconds=180), usegmt=True) + assert 179 <= provider_retry_seconds(future) <= 180 + past = format_datetime(datetime.now(UTC) - timedelta(seconds=10), usegmt=True) + assert provider_retry_seconds(past) == 30 diff --git a/backend/tests/test_open_food_facts_normalization.py b/backend/tests/test_open_food_facts_normalization.py index fde585d..b224c56 100644 --- a/backend/tests/test_open_food_facts_normalization.py +++ b/backend/tests/test_open_food_facts_normalization.py @@ -1,5 +1,8 @@ import asyncio import math +import subprocess +from email.message import Message +from urllib.error import HTTPError as UrllibHTTPError from collections.abc import Iterator from unittest.mock import AsyncMock, patch @@ -7,13 +10,16 @@ import pytest from app.source_admission import AdapterAdmissionRejected +from app.services.food_search_availability import FoodSearchAvailability, FoodSearchUnavailable from app.services.open_food_facts import ( _MAX_UPSTREAM_ATTEMPTS_PER_SEARCH, _OPEN_FOOD_FACTS_ADMISSION, _OPEN_FOOD_FACTS_RATE_GOVERNOR, + _OPEN_FOOD_FACTS_AVAILABILITY, _PRIMARY_MAX_ATTEMPTS, _FALLBACK_MAX_ATTEMPTS, _extract_nutri_score, + _curl_fetch, _to_float, search_food_products, ) @@ -22,11 +28,13 @@ @pytest.fixture(autouse=True) def reset_open_food_facts_admission() -> Iterator[None]: _OPEN_FOOD_FACTS_ADMISSION._reset_for_tests() + _OPEN_FOOD_FACTS_AVAILABILITY.reset() reset_governor = getattr(_OPEN_FOOD_FACTS_RATE_GOVERNOR, "_reset_for_tests", None) if reset_governor is not None: reset_governor() yield _OPEN_FOOD_FACTS_ADMISSION._reset_for_tests() + _OPEN_FOOD_FACTS_AVAILABILITY.reset() if reset_governor is not None: reset_governor() @@ -130,24 +138,171 @@ def test_unexpected_fallback_programming_error_is_not_hidden( @patch("app.services.open_food_facts._fetch_fallback", new_callable=AsyncMock) @patch("app.services.open_food_facts._fetch_primary", new_callable=AsyncMock) +@pytest.mark.parametrize("status", [429, 503]) def test_upstream_http_status_does_not_bypass_limit_through_fallback( primary: AsyncMock, fallback: AsyncMock, + status: int, ) -> None: request = httpx.Request("GET", "https://world.openfoodfacts.org/cgi/search.pl") - response = httpx.Response(429, request=request) + response = httpx.Response(status, headers={"Retry-After": "120"}, request=request) primary.side_effect = httpx.HTTPStatusError( "rate limited", request=request, response=response, ) - with pytest.raises(httpx.HTTPStatusError): + with pytest.raises(FoodSearchUnavailable) as first: asyncio.run(search_food_products("banana")) + assert first.value.status_code == status + assert first.value.retry_after_seconds == 120 + + # A different query must not send more source traffic during its pause. + with pytest.raises(FoodSearchUnavailable) as repeated: + asyncio.run(search_food_products("apple")) + assert repeated.value.status_code == status + assert 119 <= repeated.value.retry_after_seconds <= 120 + primary.assert_awaited_once() fallback.assert_not_awaited() +@patch("app.services.open_food_facts._fetch_primary", new_callable=AsyncMock) +def test_cached_success_survives_provider_outage_then_expires( + primary: AsyncMock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + now = [0.0] + availability = FoodSearchAvailability(ttl_seconds=300, clock=lambda: now[0]) + monkeypatch.setattr("app.services.open_food_facts._OPEN_FOOD_FACTS_AVAILABILITY", availability) + product = {"product_name": "Oats", "nutriments": { + "energy-kcal_100g": 375, "proteins_100g": 13, + "fat_100g": 7, "carbohydrates_100g": 60, + }} + primary.return_value = {"products": [product]} + first = asyncio.run(search_food_products("oats")) + first[0].product_name = "caller mutation" + + assert asyncio.run(search_food_products(" oats "))[0].product_name == "Oats" + primary.assert_awaited_once() + + request = httpx.Request("GET", "https://world.openfoodfacts.org/cgi/search.pl") + primary.side_effect = httpx.HTTPStatusError( + "down", request=request, + response=httpx.Response(503, headers={"Retry-After": "600"}, request=request), + ) + with pytest.raises(FoodSearchUnavailable): + asyncio.run(search_food_products("apple")) + assert asyncio.run(search_food_products("oats"))[0].product_name == "Oats" + assert primary.await_count == 2 + + now[0] = 301 + with pytest.raises(FoodSearchUnavailable): + asyncio.run(search_food_products("oats")) + assert primary.await_count == 2 + + now[0] = 601 + primary.side_effect = None + assert asyncio.run(search_food_products("oats"))[0].product_name == "Oats" + assert primary.await_count == 3 + + +@patch("app.services.open_food_facts._fetch_primary", new_callable=AsyncMock) +def test_empty_results_are_not_cached(primary: AsyncMock) -> None: + primary.return_value = {"products": []} + assert asyncio.run(search_food_products("oats")) == [] + assert asyncio.run(search_food_products("oats")) == [] + assert primary.await_count == 2 + + +@patch("app.services.open_food_facts._fetch_primary", new_callable=AsyncMock) +def test_pause_started_during_rate_reservation_stops_the_pending_transfer( + primary: AsyncMock, monkeypatch: pytest.MonkeyPatch, +) -> None: + primary.return_value = {"products": []} + class Governor: + async def acquire(self) -> None: + # Another in-flight search can report 503 while this reservation + # waits for the shared database governor. + _OPEN_FOOD_FACTS_AVAILABILITY.pause_provider(503, 120) + + monkeypatch.setattr("app.services.open_food_facts._OPEN_FOOD_FACTS_RATE_GOVERNOR", Governor()) + with pytest.raises(FoodSearchUnavailable): + asyncio.run(search_food_products("oats")) + primary.assert_not_awaited() + + +@pytest.mark.parametrize("exit_code", [6, 7, 28, 60]) +def test_curl_transport_failure_keeps_exit_code_without_logging_private_stderr( + exit_code: int, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, +) -> None: + monkeypatch.setattr("app.services.open_food_facts._resolve_curl_command", lambda: "curl") + transfer = subprocess.CompletedProcess( + ["curl"], exit_code, stdout=b"", stderr=b"error mentioning private-search-term", + ) + monkeypatch.setattr("app.services.open_food_facts.subprocess.run", lambda *a, **k: transfer) + with pytest.raises(ValueError, match=f"curl transport failed \\(exit {exit_code}\\)"): + _curl_fetch({"search_terms": "private-search-term"}) + messages = [r.getMessage() for r in caplog.records] + assert any(f"exit={exit_code}" in message for message in messages) + assert all("private-search-term" not in message for message in messages) + + +@pytest.mark.parametrize("transport", ["curl", "urllib"]) +@pytest.mark.parametrize("status", [429, 503]) +@patch("app.services.open_food_facts._fetch_primary", new_callable=AsyncMock) +def test_fallback_provider_pause_is_preserved_without_a_third_attempt( + primary: AsyncMock, transport: str, status: int, monkeypatch: pytest.MonkeyPatch, +) -> None: + from unittest.mock import Mock + + primary.side_effect = httpx.ReadTimeout("primary timed out") + monkeypatch.setattr( + "app.services.open_food_facts._resolve_curl_command", + lambda: "curl" if transport == "curl" else None, + ) + if transport == "curl": + transfer = Mock(return_value=subprocess.CompletedProcess( + ["curl"], 22, + stdout=(f"HTTP/1.1 200 Connection established\r\n\r\n" + f"HTTP/2 {status}\r\nRetry-After: 120\r\n\r\n\n{status}").encode(), + stderr=b"provider unavailable", + )) + monkeypatch.setattr("app.services.open_food_facts.subprocess.run", transfer) + else: + headers = Message() + headers["Retry-After"] = "120" + transfer = Mock(side_effect=UrllibHTTPError( + "https://world.openfoodfacts.org/cgi/search.pl", status, "unavailable", headers, None, + )) + monkeypatch.setattr("app.services.open_food_facts.urlopen", transfer) + + with pytest.raises(FoodSearchUnavailable) as failure: + asyncio.run(search_food_products("banana")) + assert failure.value.status_code == status + assert failure.value.retry_after_seconds == 120 + with pytest.raises(FoodSearchUnavailable): + asyncio.run(search_food_products("apple")) + primary.assert_awaited_once() + transfer.assert_called_once() + + +def test_curl_success_strips_transfer_headers_but_preserves_utf8_payload( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("app.services.open_food_facts._resolve_curl_command", lambda: "curl") + transfer = subprocess.CompletedProcess( + ["curl"], 0, + stdout=(b"HTTP/1.1 200 Connection established\r\n\r\n" + b"HTTP/2 301\r\nLocation: https://world.openfoodfacts.org/\r\n\r\n" + b'HTTP/2 200\r\nContent-Type: application/json\r\n\r\n' + + '{"products":[{"product_name":"Crème"}]}\n200'.encode()), + stderr=b"", + ) + monkeypatch.setattr("app.services.open_food_facts.subprocess.run", lambda *a, **k: transfer) + assert _curl_fetch({"search_terms": "cream"}) == {"products": [{"product_name": "Crème"}]} + + @patch("app.services.open_food_facts._fetch_fallback", new_callable=AsyncMock) @patch("app.services.open_food_facts._fetch_primary", new_callable=AsyncMock) def test_primary_transport_error_uses_single_fallback_attempt( diff --git a/docs/public/data-safety.md b/docs/public/data-safety.md index 946c623..26288c4 100644 --- a/docs/public/data-safety.md +++ b/docs/public/data-safety.md @@ -13,6 +13,14 @@ Product-search text is sent to Open Food Facts without the CalorieApp account identifier and is not retained as CalorieApp history unless the user chooses to log a result. +The food-search adapter keeps up to 64 successful, nonempty product-result sets +in process memory, reusable for up to five minutes. Lookup keys are hashed search text +and page size; the cache is not an account history and is never written to the +database or disk. Entries expire on access and disappear when the process stops. +An unexpired result can be returned without contacting an unavailable provider. +Provider 429/503 responses pause new upstream searches for Retry-After (30 seconds +when absent); no extra retry or alternate provider is used to bypass that pause. + Personal food history, email addresses, profile details and stable user identifiers are not intended for public blockchain or public IPFS storage. Optional encrypted user-controlled exports and non-reversible integrity proofs