44 "context"
55 "encoding/json"
66 "fmt"
7+ "io"
78 "log/slog"
89 "net/http"
910 "os"
@@ -22,15 +23,20 @@ const (
2223
2324// Store manages access to the models.dev data.
2425// All methods are safe for concurrent use.
26+ //
27+ // Use NewStore to obtain the process-wide singleton instance.
28+ // The database is loaded on first access via GetDatabase and
29+ // shared across all callers, avoiding redundant disk/network I/O.
2530type Store struct {
2631 cacheFile string
2732 mu sync.Mutex
2833 db * Database
34+ etag string // ETag from last successful fetch, used for conditional requests
2935}
3036
31- // NewStore creates a new models.dev store.
32- // The database is loaded on first access via GetDatabase .
33- func NewStore () (* Store , error ) {
37+ // singleton holds the process-wide Store instance. It is initialised lazily
38+ // on the first call to NewStore. All subsequent calls return the same value .
39+ var singleton = sync . OnceValues ( func () (* Store , error ) {
3440 homeDir , err := os .UserHomeDir ()
3541 if err != nil {
3642 return nil , fmt .Errorf ("failed to get user home directory: %w" , err )
@@ -44,6 +50,15 @@ func NewStore() (*Store, error) {
4450 return & Store {
4551 cacheFile : filepath .Join (cacheDir , CacheFileName ),
4652 }, nil
53+ })
54+
55+ // NewStore returns the process-wide singleton Store.
56+ //
57+ // The database is loaded lazily on the first call to GetDatabase and
58+ // then cached in memory so that every caller shares one copy.
59+ // The first call creates the cache directory if it does not exist.
60+ func NewStore () (* Store , error ) {
61+ return singleton ()
4762}
4863
4964// NewDatabaseStore creates a Store pre-populated with the given database.
@@ -63,12 +78,13 @@ func (s *Store) GetDatabase(ctx context.Context) (*Database, error) {
6378 return s .db , nil
6479 }
6580
66- db , err := loadDatabase (ctx , s .cacheFile )
81+ db , etag , err := loadDatabase (ctx , s .cacheFile )
6782 if err != nil {
6883 return nil , err
6984 }
7085
7186 s .db = db
87+ s .etag = etag
7288 return db , nil
7389}
7490
@@ -128,80 +144,117 @@ func (s *Store) GetModel(ctx context.Context, id string) (*Model, error) {
128144
129145// loadDatabase loads the database from the local cache file or
130146// falls back to fetching from the models.dev API.
131- func loadDatabase (ctx context.Context , cacheFile string ) (* Database , error ) {
147+ // It returns the database and the ETag associated with the data.
148+ func loadDatabase (ctx context.Context , cacheFile string ) (* Database , string , error ) {
132149 // Try to load from cache first
133150 cached , err := loadFromCache (cacheFile )
134151 if err == nil && time .Since (cached .LastRefresh ) < refreshInterval {
135- return & cached .Database , nil
152+ return & cached .Database , cached . ETag , nil
136153 }
137154
138- // Cache is invalid or doesn't exist, fetch from API
139- database , fetchErr := fetchFromAPI (ctx )
155+ // Cache is stale or doesn't exist — try a conditional fetch with the ETag.
156+ var etag string
157+ if cached != nil {
158+ etag = cached .ETag
159+ }
160+
161+ database , newETag , fetchErr := fetchFromAPI (ctx , etag )
140162 if fetchErr != nil {
141- // If API fetch fails, but we have cached data, use it
163+ // If API fetch fails but we have cached data, use it regardless of age.
142164 if cached != nil {
143- return & cached .Database , nil
165+ slog .Debug ("API fetch failed, using stale cache" , "error" , fetchErr )
166+ return & cached .Database , cached .ETag , nil
167+ }
168+ return nil , "" , fmt .Errorf ("failed to fetch from API and no cached data available: %w" , fetchErr )
169+ }
170+
171+ // database is nil when the server returned 304 Not Modified.
172+ if database == nil && cached != nil {
173+ // Bump LastRefresh so we don't re-check until the next interval.
174+ cached .LastRefresh = time .Now ()
175+ if saveErr := saveToCache (cacheFile , & cached .Database , cached .ETag ); saveErr != nil {
176+ slog .Warn ("Failed to update cache timestamp" , "error" , saveErr )
144177 }
145- return nil , fmt . Errorf ( "failed to fetch from API and no cached data available: %w" , fetchErr )
178+ return & cached . Database , cached . ETag , nil
146179 }
147180
148- // Save to cache
149- if err := saveToCache (cacheFile , database ); err != nil {
150- // Log the error but don't fail the request
151- slog .Warn ("Warning: failed to save to cache" , "error" , err )
181+ // Save the fresh data to cache.
182+ if saveErr := saveToCache (cacheFile , database , newETag ); saveErr != nil {
183+ slog .Warn ("Failed to save to cache" , "error" , saveErr )
152184 }
153185
154- return database , nil
186+ return database , newETag , nil
155187}
156188
157- func fetchFromAPI (ctx context.Context ) (* Database , error ) {
189+ // fetchFromAPI fetches the models.dev database.
190+ // If etag is non-empty it is sent as If-None-Match; a 304 response
191+ // returns (nil, etag, nil) to indicate no change.
192+ func fetchFromAPI (ctx context.Context , etag string ) (* Database , string , error ) {
158193 req , err := http .NewRequestWithContext (ctx , http .MethodGet , ModelsDevAPIURL , http .NoBody )
159194 if err != nil {
160- return nil , fmt .Errorf ("failed to create request: %w" , err )
195+ return nil , "" , fmt .Errorf ("failed to create request: %w" , err )
196+ }
197+
198+ if etag != "" {
199+ req .Header .Set ("If-None-Match" , etag )
161200 }
162201
163202 resp , err := (& http.Client {Timeout : 30 * time .Second }).Do (req )
164203 if err != nil {
165- return nil , fmt .Errorf ("failed to fetch from API: %w" , err )
204+ return nil , "" , fmt .Errorf ("failed to fetch from API: %w" , err )
166205 }
167206 defer resp .Body .Close ()
168207
208+ if resp .StatusCode == http .StatusNotModified {
209+ slog .Debug ("models.dev data not modified (304)" )
210+ return nil , etag , nil
211+ }
212+
169213 if resp .StatusCode != http .StatusOK {
170- return nil , fmt .Errorf ("API returned status %d" , resp .StatusCode )
214+ return nil , "" , fmt .Errorf ("API returned status %d" , resp .StatusCode )
215+ }
216+
217+ // Read the full body then unmarshal — avoids the extra intermediate
218+ // buffering that json.Decoder.Decode performs.
219+ body , err := io .ReadAll (resp .Body )
220+ if err != nil {
221+ return nil , "" , fmt .Errorf ("failed to read response body: %w" , err )
171222 }
172223
173224 var providers map [string ]Provider
174- if err := json .NewDecoder ( resp . Body ). Decode ( & providers ); err != nil {
175- return nil , fmt .Errorf ("failed to decode response: %w" , err )
225+ if err := json .Unmarshal ( body , & providers ); err != nil {
226+ return nil , "" , fmt .Errorf ("failed to decode response: %w" , err )
176227 }
177228
229+ newETag := resp .Header .Get ("ETag" )
230+
178231 return & Database {
179232 Providers : providers ,
180233 UpdatedAt : time .Now (),
181- }, nil
234+ }, newETag , nil
182235}
183236
184237func loadFromCache (cacheFile string ) (* CachedData , error ) {
185- f , err := os .Open (cacheFile )
238+ data , err := os .ReadFile (cacheFile )
186239 if err != nil {
187- return nil , fmt .Errorf ("failed to open cache file: %w" , err )
240+ return nil , fmt .Errorf ("failed to read cache file: %w" , err )
188241 }
189- defer f .Close ()
190242
191243 var cached CachedData
192- if err := json .NewDecoder ( f ). Decode ( & cached ); err != nil {
244+ if err := json .Unmarshal ( data , & cached ); err != nil {
193245 return nil , fmt .Errorf ("failed to decode cached data: %w" , err )
194246 }
195247
196248 return & cached , nil
197249}
198250
199- func saveToCache (cacheFile string , database * Database ) error {
251+ func saveToCache (cacheFile string , database * Database , etag string ) error {
200252 now := time .Now ()
201253 cached := CachedData {
202254 Database : * database ,
203255 CachedAt : now ,
204256 LastRefresh : now ,
257+ ETag : etag ,
205258 }
206259
207260 data , err := json .MarshalIndent (cached , "" , " " )
0 commit comments