Verified catalogue of mobiles and laptops sold in India, collected from real retail listings (FastAPI backend, React frontend, Postgres/pgvector). - REST API under /api/elec (read-only catalogue; admin endpoints need login) - MCP server (FastMCP) at /mcp/ with list_categories, search_products, get_product and price_history tools - Real ratings and reviews read from product pages and search results - Production Dockerfile (requirements-api.txt, no PyTorch) and .env.production.example; remote database only via an explicit ELEC_ALLOW_REMOTE_DB host/name allowlist - docs/API.md: endpoint and MCP reference with live examples Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
62 lines
2.1 KiB
Python
62 lines
2.1 KiB
Python
"""Per-host circuit breaker.
|
|
|
|
One 403, 429, 503 or CAPTCHA page opens the breaker for that host for
|
|
ELEC_BREAKER_COOLDOWN_HOURS. While it is open the host is not requested at all
|
|
and its products are collected from web search results instead. There is no
|
|
retry-with-a-different-identity: a block is an answer.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
import time
|
|
from typing import Callable, Dict, Optional, Tuple
|
|
|
|
from app.infrastructure.settings import ELEC_BREAKER_COOLDOWN_HOURS
|
|
|
|
|
|
class CircuitBreaker:
|
|
def __init__(
|
|
self,
|
|
cooldown_seconds: float = ELEC_BREAKER_COOLDOWN_HOURS * 3600,
|
|
on_trip: Optional[Callable[[str, str, float], None]] = None,
|
|
clock: Callable[[], float] = time.time,
|
|
) -> None:
|
|
self.cooldown = cooldown_seconds
|
|
self.on_trip = on_trip
|
|
self._clock = clock
|
|
self._open: Dict[str, Tuple[float, str]] = {}
|
|
self._lock = threading.Lock()
|
|
|
|
@staticmethod
|
|
def _key(host: str) -> str:
|
|
host = host.lower()
|
|
return host[4:] if host.startswith("www.") else host
|
|
|
|
def preload(self, host: str, until_epoch: float, reason: str) -> None:
|
|
"""Restore a breaker that was opened in an earlier run (elec.site)."""
|
|
if until_epoch > self._clock():
|
|
with self._lock:
|
|
self._open[self._key(host)] = (until_epoch, reason)
|
|
|
|
def trip(self, host: str, reason: str) -> None:
|
|
until = self._clock() + self.cooldown
|
|
with self._lock:
|
|
self._open[self._key(host)] = (until, reason)
|
|
if self.on_trip:
|
|
self.on_trip(self._key(host), reason, until)
|
|
|
|
def is_open(self, host: str) -> bool:
|
|
key = self._key(host)
|
|
with self._lock:
|
|
entry = self._open.get(key)
|
|
if not entry:
|
|
return False
|
|
if entry[0] <= self._clock():
|
|
del self._open[key]
|
|
return False
|
|
return True
|
|
|
|
def reason(self, host: str) -> Optional[str]:
|
|
entry = self._open.get(self._key(host))
|
|
return entry[1] if entry else None
|