22import asyncio
33import logging
44import time
5- from collections import defaultdict
65from datetime import datetime , timezone , timedelta
76
87import httpx
@@ -23,15 +22,22 @@ class RateLimiter:
2322 def __init__ (self , max_hits : int , window_seconds : int ):
2423 self .max_hits = max_hits
2524 self .window = window_seconds
26- self ._hits : dict [str , list [float ]] = defaultdict ( list )
25+ self ._hits : dict [str , list [float ]] = {}
2726
2827 def is_limited (self , key : str ) -> bool :
2928 now = time .monotonic ()
30- bucket = self ._hits [key ]
31- self ._hits [key ] = [t for t in bucket if now - t < self .window ]
32- if len (self ._hits [key ]) >= self .max_hits :
29+ bucket = self ._hits .get (key )
30+ if bucket is not None :
31+ bucket = [t for t in bucket if now - t < self .window ]
32+ if not bucket :
33+ del self ._hits [key ]
34+ bucket = []
35+ else :
36+ bucket = []
37+ if len (bucket ) >= self .max_hits :
3338 return True
34- self ._hits [key ].append (now )
39+ bucket .append (now )
40+ self ._hits [key ] = bucket
3541 return False
3642
3743
@@ -42,39 +48,17 @@ def is_limited(self, key: str) -> bool:
4248) # 6 searches per min per IP
4349
4450
45- # ── Nominatim search proxy with in-memory cache ──────
51+ # ── Nominatim search proxy ────────────────────── ──────
4652
4753NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"
4854NOMINATIM_USER_AGENT = (
4955 "WhereThePlow/1.0 (St. John's snowplow tracker; https://plow.jackharrhy.dev)"
5056)
5157ST_JOHNS_VIEWBOX = "-52.85,47.45,-52.55,47.65"
52- SEARCH_CACHE_TTL = 86400 # 24 hours — addresses don't change often
53- SEARCH_CACHE_MAX = 500 # max entries before evicting oldest
5458
55- _search_cache : dict [str , tuple [float , list [dict ]]] = {} # key -> (expires_at, results)
5659_nominatim_last_request : float = 0.0 # monotonic timestamp of last outbound request
5760
5861
59- def _search_cache_get (key : str ) -> list [dict ] | None :
60- entry = _search_cache .get (key )
61- if entry is None :
62- return None
63- expires_at , results = entry
64- if time .monotonic () > expires_at :
65- del _search_cache [key ]
66- return None
67- return results
68-
69-
70- def _search_cache_put (key : str , results : list [dict ]) -> None :
71- # Evict oldest entries if cache is full
72- if len (_search_cache ) >= SEARCH_CACHE_MAX :
73- oldest_key = min (_search_cache , key = lambda k : _search_cache [k ][0 ])
74- del _search_cache [oldest_key ]
75- _search_cache [key ] = (time .monotonic () + SEARCH_CACHE_TTL , results )
76-
77-
7862def _client_ip (request : Request ) -> str :
7963 return request .headers .get ("x-forwarded-for" , "" ).split ("," )[0 ].strip () or (
8064 request .client .host if request .client else "unknown"
@@ -460,7 +444,7 @@ async def search_address(
460444
461445 cache_key = q .strip ().lower ()
462446
463- cached = _search_cache_get (cache_key )
447+ cached = cache . search_get (cache_key )
464448 if cached is not None :
465449 return JSONResponse (content = cached )
466450
@@ -495,7 +479,7 @@ async def search_address(
495479
496480 raw = resp .json ()
497481 results = [_format_search_result (r ) for r in raw ]
498- _search_cache_put (cache_key , results )
482+ cache . search_put (cache_key , results )
499483 return JSONResponse (content = results )
500484
501485 except httpx .TimeoutException :
0 commit comments