-
Notifications
You must be signed in to change notification settings - Fork 0
Add Hygraph circuit breaker and caching with fallback metrics #124
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| """Simple cache helper that prefers Redis but falls back to an in-memory store.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import json | ||
| import os | ||
| import threading | ||
| import time | ||
| from typing import Any, Dict, Optional | ||
|
|
||
| try: # pragma: no cover - optional dependency | ||
| import redis # type: ignore | ||
| except Exception: # pragma: no cover - optional dependency | ||
| redis = None # type: ignore[assignment] | ||
|
|
||
|
|
||
| class RedisCache: | ||
| """Tiny wrapper that abstracts Redis availability.""" | ||
|
|
||
| def __init__(self, url: str | None = None, *, namespace: str = "hygraph") -> None: | ||
| self._url = url or os.getenv("HYGRAPH_CACHE_URL") or os.getenv("REDIS_URL") | ||
| self._namespace = namespace | ||
| self._lock = threading.Lock() | ||
| self._memory: Dict[str, tuple[str, Optional[float]]] = {} | ||
| if redis is not None and self._url: | ||
| self._client = redis.Redis.from_url(self._url, decode_responses=True) | ||
| else: # pragma: no cover - exercised implicitly when redis missing | ||
| self._client = None | ||
|
|
||
| def _ns(self, key: str) -> str: | ||
| return f"{self._namespace}:{key}" | ||
|
|
||
| def get(self, key: str) -> Any | None: | ||
| namespaced = self._ns(key) | ||
| if self._client is not None: | ||
| raw = self._client.get(namespaced) | ||
| return json.loads(raw) if raw else None | ||
| with self._lock: | ||
| value = self._memory.get(namespaced) | ||
| if value is None: | ||
| return None | ||
| raw, expires_at = value | ||
| if expires_at is not None and expires_at < time.time(): | ||
| del self._memory[namespaced] | ||
| return None | ||
| return json.loads(raw) | ||
|
|
||
| def set(self, key: str, value: Any, *, ttl: int | None = None) -> None: | ||
| payload = json.dumps(value) | ||
| namespaced = self._ns(key) | ||
| if self._client is not None: | ||
| if ttl is not None: | ||
| self._client.setex(namespaced, ttl, payload) | ||
| else: | ||
| self._client.set(namespaced, payload) | ||
| return | ||
| expires_at = (time.time() + ttl) if ttl else None | ||
| with self._lock: | ||
| self._memory[namespaced] = (payload, expires_at) | ||
|
|
||
| def clear(self, key: str) -> None: | ||
| namespaced = self._ns(key) | ||
| if self._client is not None: | ||
| self._client.delete(namespaced) | ||
| return | ||
|
||
| with self._lock: | ||
| self._memory.pop(namespaced, None) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| """Circuit breaker utility for wrapping outbound service calls.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import time | ||
| from dataclasses import dataclass, field | ||
| from typing import Any, Callable, TypeVar | ||
|
|
||
|
|
||
| T = TypeVar("T") | ||
|
|
||
|
|
||
| class CircuitOpenError(RuntimeError): | ||
| """Raised when the circuit breaker refuses to execute a call.""" | ||
|
|
||
| def __init__(self, message: str = "Circuit breaker is open", *, fallback: Any | None = None) -> None: | ||
| super().__init__(message) | ||
| self.fallback = fallback | ||
|
|
||
|
|
||
| @dataclass | ||
| class CircuitBreaker: | ||
| """Simple circuit breaker implementation with configurable thresholds.""" | ||
|
|
||
| failure_threshold: int = 5 | ||
| recovery_timeout: float = 30.0 | ||
| success_threshold: int = 1 | ||
| state: str = field(default="CLOSED", init=False) | ||
| failure_count: int = field(default=0, init=False) | ||
| success_count: int = field(default=0, init=False) | ||
| last_failure_time: float | None = field(default=None, init=False) | ||
|
|
||
| CLOSED: str = field(default="CLOSED", init=False) | ||
| OPEN: str = field(default="OPEN", init=False) | ||
| HALF_OPEN: str = field(default="HALF_OPEN", init=False) | ||
|
|
||
| def call(self, func: Callable[..., T], *args: Any, **kwargs: Any) -> T: | ||
| """Execute ``func`` respecting the breaker state machine.""" | ||
|
|
||
| now = time.monotonic() | ||
| if self.state == self.OPEN: | ||
| if self.last_failure_time is None or (now - self.last_failure_time) < self.recovery_timeout: | ||
| raise CircuitOpenError() | ||
| self.state = self.HALF_OPEN | ||
| self.success_count = 0 | ||
|
|
||
| try: | ||
| result = func(*args, **kwargs) | ||
| except Exception: # noqa: BLE001 | ||
| self._record_failure() | ||
| raise | ||
|
|
||
| self._record_success() | ||
| return result | ||
|
|
||
| def _record_failure(self) -> None: | ||
| now = time.monotonic() | ||
| if self.state == self.HALF_OPEN: | ||
| self._trip(now) | ||
| return | ||
|
|
||
| self.failure_count += 1 | ||
| if self.failure_count >= self.failure_threshold: | ||
| self._trip(now) | ||
|
|
||
| def _record_success(self) -> None: | ||
| if self.state == self.HALF_OPEN: | ||
| self.success_count += 1 | ||
| if self.success_count >= self.success_threshold: | ||
| self._reset() | ||
| else: | ||
| self._reset() | ||
|
|
||
| def _trip(self, when: float) -> None: | ||
| self.state = self.OPEN | ||
| self.last_failure_time = when | ||
| self.failure_count = 0 | ||
| self.success_count = 0 | ||
|
|
||
| def _reset(self) -> None: | ||
| self.state = self.CLOSED | ||
| self.failure_count = 0 | ||
| self.success_count = 0 | ||
| self.last_failure_time = None |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Address the pipeline failure by refining the type-ignore comment.
The pipeline failure at line 12 indicates that the broad
# type: ignorecomment may be unnecessary or should be more specific. Consider using a more precise suppression like# type: ignore[import-not-found]or remove it if the import can be typed correctly withTYPE_CHECKINGguards.Note: Also consider catching only
ImportErrorrather than the broadExceptionfor clarity.📝 Committable suggestion
🧰 Tools
🪛 GitHub Actions: CI
[error] 12-12: Typos/lint issues detected by ruff/typos; possibly unused type-ignore comments or redefinition.
🤖 Prompt for AI Agents