From 4efac197de045e07b7bfffe8a8957c3ec60e611c Mon Sep 17 00:00:00 2001 From: John Rogers Date: Mon, 21 Sep 2026 21:59:50 +0100 Subject: [PATCH] Make InstrumentsCache load resilient to null list fields The harmony schemas declare closest_catalogue_instrument_matches and questions[].seen_in_catalogue_instruments as non-Optional lists with a None default. Pydantic v2 does not validate defaults, so model_dump() writes them as null, but model_validate() rejects null on the next start. Any restart after the cache had been written therefore died at import and restart=always crash-looped (2026-09-20 incident). Normalise those nulls to [] on load, and skip any entry that still fails validation instead of aborting startup. Co-Authored-By: Claude Opus 5 (cherry picked from commit 70d650d10317039dc0e8172026e44840230a95a4) --- harmony_api/services/instruments_cache.py | 31 ++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/harmony_api/services/instruments_cache.py b/harmony_api/services/instruments_cache.py index c8e5265..9269738 100644 --- a/harmony_api/services/instruments_cache.py +++ b/harmony_api/services/instruments_cache.py @@ -30,6 +30,7 @@ from typing import List from harmony.schemas.requests.text import Instrument +from pydantic import ValidationError from harmony_api import constants from harmony_api.utils.singleton_meta import SingletonMeta @@ -67,11 +68,39 @@ def __load(self): # Dict to instruments cache_parsed: dict[str, List[Instrument]] = {} for key, value in cache.items(): - instruments = [Instrument.model_validate(x) for x in value] + try: + instruments = [ + Instrument.model_validate(self.__normalise_instrument_dict(x)) + for x in value + ] + except (ValidationError, TypeError, AttributeError) as e: + # A corrupt entry must never take the whole API down at + # startup (restart=always would crash loop). Drop it and + # let it be recomputed on the next request. + print(f"WARNING: Skipping invalid instruments cache entry {key}: {e}") + continue cache_parsed[key] = instruments self.__cache = cache_parsed + @staticmethod + def __normalise_instrument_dict(instrument: dict) -> dict: + """ + Coerce fields that the Instrument/Question schemas declare as + non-Optional lists with a None default. model_dump() writes them + as null, but model_validate() rejects null, so a saved cache + would otherwise fail to load on the next start. + """ + + if instrument.get("closest_catalogue_instrument_matches") is None: + instrument["closest_catalogue_instrument_matches"] = [] + + for question in instrument.get("questions") or []: + if question.get("seen_in_catalogue_instruments") is None: + question["seen_in_catalogue_instruments"] = [] + + return instrument + def set(self, key: str, value: List[Instrument]): """ :param key: The cache key.