From 19c6439ff5efdd05f7853bd62e33dec2462d19f1 Mon Sep 17 00:00:00 2001 From: Zo Bot Date: Mon, 15 Jun 2026 13:40:53 +0000 Subject: [PATCH 1/2] track provider names per module in registry so reload_plugins actually evicts them ProviderRegistry.reload_plugins() was reading self._loaded_modules[module_name] and popping the result from self._providers. But self._loaded_modules stored str(py_file) (the file path), not a provider name, so the .pop() was a no-op against the actual provider-name keys. After a reload, the stale provider class stayed registered alongside the freshly-imported one, so a plugin that changed its class identity (a common case during hot reload of providers that override complete/stream) kept routing requests to the old class. Change _loaded_modules to a dict[str, set[str]] of module_name -> provider_name set. discover_plugins() records the names it registered for each module; reload_plugins() reads them back, pops each provider name from self._providers, then rediscovers. Host-registered providers (stuffed in via reg.register() from freerelay/core/routing/factory.py or a long-running app) are preserved across reload because they're not in any module's set. Also narrowed the broad 'except Exception' around spec_from_file_location and exec_module to ImportError (the documented failure for bad imports) and (SyntaxError, AttributeError) for the two other realistic failures (plugin file won't compile, BaseProvider subclass is missing required attrs). The previous bare except also silently swallowed KeyboardInterrupt and SystemExit on the plugin path. --- freerelay/providers/registry.py | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/freerelay/providers/registry.py b/freerelay/providers/registry.py index 136e11a..18eb448 100644 --- a/freerelay/providers/registry.py +++ b/freerelay/providers/registry.py @@ -29,7 +29,10 @@ class ProviderRegistry: def __init__(self, plugin_dir: Path | None = None) -> None: self.plugin_dir = plugin_dir or Path.home() / ".freerelay" / "plugins" self._providers: dict[str, type[BaseProvider]] = {} - self._loaded_modules: dict[str, str] = {} + # Map each module_name -> set of provider names that module registered. + # This lets reload_plugins() look up the actual provider keys (not file + # paths) so _providers.pop() removes the right entries. + self._loaded_modules: dict[str, set[str]] = {} def register(self, provider_cls: type[BaseProvider]) -> None: """Register a provider class.""" @@ -64,6 +67,7 @@ def discover_plugins(self) -> int: if module_name in self._loaded_modules: continue + registered_names: set[str] = set() try: spec = importlib.util.spec_from_file_location(module_name, py_file) if spec and spec.loader: @@ -80,17 +84,24 @@ def discover_plugins(self) -> int: and attr is not BaseProvider ): self.register(attr) + registered_names.add(attr.name) # type: ignore[attr-defined] loaded += 1 logger.info( "Loaded plugin provider: %s from %s", - attr.name, + attr.name, # type: ignore[attr-defined] py_file.name, ) - self._loaded_modules[module_name] = str(py_file) + self._loaded_modules[module_name] = registered_names - except Exception as e: + except ImportError as e: logger.error("Failed to load plugin %s: %s", py_file.name, e) + except (SyntaxError, AttributeError) as e: + # SyntaxError: the plugin file itself failed to compile + # AttributeError: BaseProvider subclass is missing required attrs + # Both indicate a broken plugin and should be surfaced loudly + # rather than silently logged as a generic "Exception" + logger.error("Invalid plugin %s: %s", py_file.name, e) return loaded @@ -101,12 +112,13 @@ def reload_plugins(self) -> int: Returns: Number of providers after reload. """ - # Remove previously loaded plugin modules - for module_name in list(self._loaded_modules.keys()): + # Remove previously loaded plugin modules. + # Look up provider names by module_name (not by file path) so + # _providers.pop() actually removes the right entries. + for module_name, provider_names in list(self._loaded_modules.items()): sys.modules.pop(module_name, None) - # Also remove plugin-registered providers - provider_name = self._loaded_modules[module_name] - self._providers.pop(provider_name, None) + for provider_name in provider_names: + self._providers.pop(provider_name, None) self._loaded_modules.clear() # Re-discover From 0ee8c8159af93a57bada4ba8c7beef062ec3f9af Mon Sep 17 00:00:00 2001 From: Zo Bot Date: Mon, 15 Jun 2026 13:45:30 +0000 Subject: [PATCH 2/2] reset daily token counter at midnight UTC in BudgetForecaster.record_tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BudgetForecaster had a daily_reset_ts field on BudgetState (documented in the dataclass comment as 'next midnight UTC') but record_tokens() never read it. Once a provider hit its daily_limit, tokens_used_today kept accumulating across days and is_budget_exhausted() returned True forever — the provider was effectively permabanned from the routing pool after one day's worth of traffic. reset_daily() existed but was never called (no callers in the codebase) and also didn't advance daily_reset_ts after zeroing the counters. Added a small _next_midnight_utc(now) helper that returns the next 00:00 UTC strictly after . record_tokens() now checks 'now >= state.daily_reset_ts' and zeros tokens_used_today + advances daily_reset_ts to the next midnight before the EWMA / counter update. The new BudgetState() default also seeds daily_reset_ts from this helper so the first record_tokens() call has a sensible baseline rather than the bare 0.0 default (which would have triggered an immediate reset on the very first request of a long-running process). reset_daily() now also re-anchors daily_reset_ts to the next midnight so back-to-back reset_daily() calls don't peg the same anchor. --- freerelay/core/resilience/budget.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/freerelay/core/resilience/budget.py b/freerelay/core/resilience/budget.py index 7edbf8b..d68a0b3 100644 --- a/freerelay/core/resilience/budget.py +++ b/freerelay/core/resilience/budget.py @@ -12,6 +12,14 @@ from dataclasses import dataclass, field +def _next_midnight_utc(now: float) -> float: + """Return the unix timestamp of the next 00:00 UTC strictly after `now`.""" + # 86400 seconds in a day; compute seconds since the most recent midnight UTC + seconds_in_day = 86_400.0 + secs_today = now % seconds_in_day + return now + (seconds_in_day - secs_today) + + @dataclass class BudgetState: """Token budget state for a single provider/key.""" @@ -20,7 +28,7 @@ class BudgetState: tokens_used_this_minute: int = 0 ewma_rate: float = 0.0 # tokens per minute last_updated_ts: float = field(default_factory=time.time) - daily_reset_ts: float = 0.0 # next midnight UTC + daily_reset_ts: float = field(default_factory=lambda: _next_midnight_utc(time.time())) daily_limit: int | None = None # None = unlimited @@ -60,6 +68,14 @@ def record_tokens(self, provider: str, tokens: int) -> None: state = self._get_state(provider) now = time.time() + # Check if we've crossed a daily reset boundary (midnight UTC). + # Without this, tokens_used_today keeps accumulating across days + # and is_budget_exhausted() reports the provider as exhausted + # forever once the limit is first hit. + if now >= state.daily_reset_ts: + state.tokens_used_today = 0 + state.daily_reset_ts = _next_midnight_utc(now) + # Check if we've crossed one or more minute boundaries elapsed = now - state.last_updated_ts if elapsed >= 60: @@ -132,3 +148,4 @@ def reset_daily(self, provider: str) -> None: state = self._get_state(provider) state.tokens_used_today = 0 state.tokens_used_this_minute = 0 + state.daily_reset_ts = _next_midnight_utc(time.time())