Skip to content

Add Hygraph circuit breaker and caching with fallback metrics#124

Merged
shayancoin merged 3 commits intomainfrom
codex/implement-circuit-breaker-and-hygraph-client-m5f9cc
Oct 18, 2025
Merged

Add Hygraph circuit breaker and caching with fallback metrics#124
shayancoin merged 3 commits intomainfrom
codex/implement-circuit-breaker-and-hygraph-client-m5f9cc

Conversation

@shayancoin
Copy link
Owner

@shayancoin shayancoin commented Oct 16, 2025

Summary

  • add a reusable circuit breaker utility and HTTPX Hygraph client with cache-aware fallbacks
  • update the Hygraph service and sync routes to use cached payloads, raise CircuitOpenError, and export new Prometheus counters
  • document runbook steps for warming caches and exercising breaker states

Testing

  • pytest tests/test_sync_routes_metrics.py (fails: missing optional dependency sqlalchemy in the execution environment)

https://chatgpt.com/codex/tasks/task_e_68f13509f73c83308d2ebd4ab42db96d

Summary by CodeRabbit

  • Bug Fixes

    • Improved error handling and fallback mechanisms for external data source integration
    • Enhanced circuit breaker metrics tracking for improved system observability
  • Documentation

    • Added operational runbook documenting cache management and circuit breaker procedures

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Oct 16, 2025

Caution

Review failed

The pull request is closed.

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

Walkthrough

Circuit breaker fallback handling is introduced for Hygraph GraphQL operations in the sync routes. Changes include a payload normalization helper, CircuitOpenError exception handling with fallback response support, circuit-breaker-specific metrics tracking, and operational documentation for cache warming and breaker state transitions.

Changes

Cohort / File(s) Summary
Circuit Breaker Error Handling
backend/api/routes_sync.py
Adds _processed_value() helper to normalize payloads; enhances hygraph_pull() with CircuitOpenError catching, fallback response construction with breaker_open flag, and hygraph_cb_trips_total and hygraph_cb_fallback_total metrics; updates error envelope for circuit-open scenarios with 503 responses; modifies hygraph_webhook() to handle raw JSON parsing with no-assignment form.
Operational Documentation
docs/backend-development.md
Adds Hygraph Circuit Breaker Runbook describing Redis-backed cache with circuit breaker for outbound GraphQL traffic; includes operational checklists for cache warming, breaker state transitions (Closed→Open, Open→Fallback, Half-Open→Closed), specific endpoints, Redis keys, and Prometheus metrics for observability.

Sequence Diagram

sequenceDiagram
    participant Client
    participant hygraph_pull as hygraph_pull()
    participant Hygraph
    participant Cache
    participant Metrics

    rect rgb(240, 248, 255)
    Note over Client,Metrics: Normal Flow (Breaker Closed)
    Client->>hygraph_pull: request
    hygraph_pull->>Hygraph: GraphQL query
    Hygraph-->>hygraph_pull: success
    hygraph_pull->>Cache: update cache
    hygraph_pull-->>Client: 200 + data
    end

    rect rgb(255, 240, 245)
    Note over Client,Metrics: Circuit Open with Fallback
    Client->>hygraph_pull: request
    hygraph_pull->>Hygraph: GraphQL query
    Hygraph-->>hygraph_pull: CircuitOpenError
    hygraph_pull->>Metrics: increment hygraph_cb_trips_total
    hygraph_pull->>Cache: retrieve fallback
    Cache-->>hygraph_pull: cached result
    hygraph_pull->>Metrics: increment hygraph_cb_fallback_total
    hygraph_pull-->>Client: 200 + fallback + breaker_open flag
    end

    rect rgb(255, 228, 225)
    Note over Client,Metrics: Circuit Open without Fallback
    Client->>hygraph_pull: request
    hygraph_pull->>Hygraph: GraphQL query
    Hygraph-->>hygraph_pull: CircuitOpenError
    hygraph_pull->>Metrics: increment hygraph_cb_trips_total
    hygraph_pull->>Cache: retrieve fallback
    Cache-->>hygraph_pull: none
    hygraph_pull-->>Client: 503 + error envelope
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

New conditional logic for CircuitOpenError handling with metrics increments and fallback response construction requires careful tracing through error paths. Changes are contained to one endpoint but introduce multiple decision points and state dependencies that warrant systematic review across both success and failure scenarios.

Possibly related PRs

Poem

🔌 A circuit breaks, but hops prevail,
With fallback cache, we'll never fail,
When Hygraph sleeps, Redis awakes,
Metrics hop along—no request forsakes! 🐰

Pre-merge checks and finishing touches

❌ Failed checks (2 warnings)
Check name Status Explanation Resolution
Description Check ⚠️ Warning The pull request description does not follow the repository’s required template and is missing the PR Type heading, the concise Short Description section, and a dedicated Tests Added section, instead using custom "Summary" and "Testing" headings that do not align with the template. Please restructure the description to match the template by adding a PR Type (e.g., Feature), a brief Short Description, and a Tests Added section that lists any new or modified tests.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (1 passed)
Check name Status Explanation
Title Check ✅ Passed The title clearly and concisely summarizes the primary additions of the pull request by highlighting the introduction of a Hygraph circuit breaker, caching, and associated fallback metrics without extraneous detail, making it easy for reviewers to understand the core change at a glance.

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8f09a21 and 5034deb.

📒 Files selected for processing (1)
  • backend/api/routes_sync.py (5 hunks)

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting

Comment on lines 169 to 188
async def pull_all(cls, db, page_size: Optional[int] = None) -> Dict[str, Any]:
try:
materials = await cls.pull_materials(db, page_size=page_size)
modules = await cls.pull_modules(db, page_size=page_size)
systems = await cls.pull_systems(db, page_size=page_size)
except CircuitOpenError as err:
fallback_raw = err.fallback
if isinstance(fallback_raw, dict) and "resource" in fallback_raw:
fallback = {fallback_raw["resource"]: fallback_raw}
elif isinstance(fallback_raw, dict):
fallback = fallback_raw
else:
fallback = {}
if not fallback:
fallback = {}
for key in ("materials", "modules", "systems"):
cached = _CACHE.get(key) or []
if cached:
fallback[key] = _fallback_envelope(key, cached)
raise CircuitOpenError("Hygraph circuit open", fallback=fallback) from err

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve cached results for all resources when circuit opens

When pull_all catches CircuitOpenError, it constructs a fallback payload from the exception but only populates caches for other resources if the fallback is empty. If the breaker trips while fetching one resource (e.g., modules) and the exception contains fallback data for that single resource, the if not fallback: block is skipped and no cached data for the other resource types is added, even though it is available or was just fetched successfully. Callers of pull_all therefore receive a partial response missing keys such as materials and systems, making the sync API inconsistent and dropping metrics for the resources that were actually processed. Consider merging the cached envelopes for any missing resource keys before re‑raising the circuit‑open error so the fallback response always contains all resource types.

Useful? React with 👍 / 👎.

Comment on lines 33 to 65
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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge RedisCache does not degrade gracefully when Redis is unreachable

The cache helper advertises falling back to an in‑memory store, but get, set, and clear call the Redis client without handling redis.exceptions.RedisError. If Redis is configured but unavailable or times out, these methods will raise and propagate the connection error, causing sync endpoints to fail instead of transparently using the in‑process cache. Wrapping the Redis operations in try/except and falling back to _memory on failure would align the implementation with the intended resilience.

Useful? React with 👍 / 👎.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

📜 Review details

Configuration used: CodeRabbit UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d2a35c4 and b0b9129.

📒 Files selected for processing (8)
  • backend/api/metrics.py (1 hunks)
  • backend/api/routes_sync.py (7 hunks)
  • backend/pyproject.toml (1 hunks)
  • backend/services/cache.py (1 hunks)
  • backend/services/circuit_breaker.py (1 hunks)
  • backend/services/hygraph_client.py (1 hunks)
  • backend/services/hygraph_service.py (2 hunks)
  • docs/backend-development.md (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
backend/services/hygraph_client.py (1)
backend/services/circuit_breaker.py (3)
  • CircuitBreaker (22-84)
  • CircuitOpenError (13-18)
  • call (37-54)
backend/services/hygraph_service.py (4)
backend/api/config.py (1)
  • get_settings (66-69)
backend/services/cache.py (2)
  • RedisCache (17-67)
  • get (33-46)
backend/services/circuit_breaker.py (1)
  • CircuitOpenError (13-18)
backend/services/hygraph_client.py (2)
  • HygraphClient (12-69)
  • execute (32-65)
🪛 GitHub Actions: CI
backend/api/routes_sync.py

[error] 109-109: Mypy: Incompatible return value type: JSONResponse cannot be assigned to dict[str, Any].

backend/services/hygraph_service.py

[error] 68-68: Mypy: Function is missing a type annotation for one or more arguments.

backend/services/cache.py

[warning] 12-14: Unused type: ignore comments flagged by linter (could remove or fix instead).

🪛 LanguageTool
docs/backend-development.md

[grammar] ~177-~177: Use correct spacing
Context: ...edoc ## Hygraph Circuit Breaker Runbook The Hygraph service layer now caches pay...

(QB_NEW_EN_OTHER_ERROR_IDS_5)


[grammar] ~179-~179: Use correct spacing
Context: ...hecklist when operating the integration: ### Warming the Cache 1. Ensure the Redis i...

(QB_NEW_EN_OTHER_ERROR_IDS_5)


[grammar] ~181-~181: Use correct spacing
Context: ... the integration: ### Warming the Cache 1. Ensure the Redis instance defined by `HY...

(QB_NEW_EN_OTHER_ERROR_IDS_5)


[grammar] ~185-~185: Use correct spacing
Context: ...l page size to populate cached datasets: - POST /api/sync/hygraph/pull with {"type": "materials"} - `POST ...

(QB_NEW_EN_OTHER_ERROR_IDS_5)


[grammar] ~186-~186:
Context: ... - POST /api/sync/hygraph/pull with {"type": "materials"} - POST /api/sync/hygraph/pull with {"type": "modules"} - `POST /a...

(QB_NEW_EN_OTHER_ERROR_IDS_)


[grammar] ~187-~187:
Context: ... - POST /api/sync/hygraph/pull with {"type": "modules"} - POST /api/sync/hygraph/pull with {"type": "systems"} 4. Confirm th...

(QB_NEW_EN_OTHER_ERROR_IDS_)


[grammar] ~188-~188: There might be a mistake here.
Context: ... - POST /api/sync/hygraph/pull with {"type": "systems"} 4. Confirm the Redis keys `hygraph:material...

(QB_NEW_EN)


[grammar] ~189-~189: Use correct spacing
Context: ... JSON payloads with the expected counts. ### Exercising Breaker States 1. **Closed →...

(QB_NEW_EN_OTHER_ERROR_IDS_5)


[grammar] ~191-~191: Use correct spacing
Context: ...d counts. ### Exercising Breaker States 1. Closed → Open: Stop outbound connectiv...

(QB_NEW_EN_OTHER_ERROR_IDS_5)


[grammar] ~193-~193: There might be a mistake here.
Context: ...r failure_threshold attempts (default 5) the breaker opens and the Prometheus co...

(QB_NEW_EN_OTHER)


[grammar] ~193-~193: There might be a mistake here.
Context: ...ter hygraph_cb_trips_total increments. 2. Open → Fallback: While the breaker is ...

(QB_NEW_EN)

🔇 Additional comments (1)
backend/api/routes_sync.py (1)

71-168: ...

Comment on lines 133 to 194
async def pull_materials(cls, db, page_size: Optional[int] = None) -> Dict[str, Any]:
query = """
query Materials($first: Int!) {
materials(first: $first) {
id
updatedAt
}
}
"""
return await cls._pull_resource("materials", query, page_size=page_size)

@classmethod
async def pull_modules(cls, db, page_size: Optional[int] = None) -> Dict[str, Any]:
query = """
query Modules($first: Int!) {
modules(first: $first) {
id
updatedAt
}
}
"""
return await cls._pull_resource("modules", query, page_size=page_size)

@classmethod
async def pull_systems(cls, db, page_size: Optional[int] = None) -> Dict[str, Any]:
query = """
query Systems($first: Int!) {
systems(first: $first) {
id
updatedAt
}
}
"""
return await cls._pull_resource("systems", query, page_size=page_size)

@classmethod
async def pull_all(cls, db, page_size: Optional[int] = None) -> Dict[str, Any]:
try:
materials = await cls.pull_materials(db, page_size=page_size)
modules = await cls.pull_modules(db, page_size=page_size)
systems = await cls.pull_systems(db, page_size=page_size)
except CircuitOpenError as err:
fallback_raw = err.fallback
if isinstance(fallback_raw, dict) and "resource" in fallback_raw:
fallback = {fallback_raw["resource"]: fallback_raw}
elif isinstance(fallback_raw, dict):
fallback = fallback_raw
else:
fallback = {}
if not fallback:
fallback = {}
for key in ("materials", "modules", "systems"):
cached = _CACHE.get(key) or []
if cached:
fallback[key] = _fallback_envelope(key, cached)
raise CircuitOpenError("Hygraph circuit open", fallback=fallback) from err

return {
"materials": materials["processed"],
"modules": modules["processed"],
"systems": systems["processed"],
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Annotate the db parameter to unblock mypy.

pull_materials, pull_modules, pull_systems, and pull_all now fail CI with “Function is missing a type annotation for one or more arguments” because db is untyped. Please add an annotation (e.g. Any or the concrete session type you expect) to each of these signatures.

-    async def pull_materials(cls, db, page_size: Optional[int] = None) -> Dict[str, Any]:
+    async def pull_materials(cls, db: Any, page_size: Optional[int] = None) -> Dict[str, Any]:

Repeat the same fix for the other pull_* methods so the mypy check passes.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async def pull_materials(cls, db, page_size: Optional[int] = None) -> Dict[str, Any]:
query = """
query Materials($first: Int!) {
materials(first: $first) {
id
updatedAt
}
}
"""
return await cls._pull_resource("materials", query, page_size=page_size)
@classmethod
async def pull_modules(cls, db, page_size: Optional[int] = None) -> Dict[str, Any]:
query = """
query Modules($first: Int!) {
modules(first: $first) {
id
updatedAt
}
}
"""
return await cls._pull_resource("modules", query, page_size=page_size)
@classmethod
async def pull_systems(cls, db, page_size: Optional[int] = None) -> Dict[str, Any]:
query = """
query Systems($first: Int!) {
systems(first: $first) {
id
updatedAt
}
}
"""
return await cls._pull_resource("systems", query, page_size=page_size)
@classmethod
async def pull_all(cls, db, page_size: Optional[int] = None) -> Dict[str, Any]:
try:
materials = await cls.pull_materials(db, page_size=page_size)
modules = await cls.pull_modules(db, page_size=page_size)
systems = await cls.pull_systems(db, page_size=page_size)
except CircuitOpenError as err:
fallback_raw = err.fallback
if isinstance(fallback_raw, dict) and "resource" in fallback_raw:
fallback = {fallback_raw["resource"]: fallback_raw}
elif isinstance(fallback_raw, dict):
fallback = fallback_raw
else:
fallback = {}
if not fallback:
fallback = {}
for key in ("materials", "modules", "systems"):
cached = _CACHE.get(key) or []
if cached:
fallback[key] = _fallback_envelope(key, cached)
raise CircuitOpenError("Hygraph circuit open", fallback=fallback) from err
return {
"materials": materials["processed"],
"modules": modules["processed"],
"systems": systems["processed"],
}
async def pull_materials(cls, db: Any, page_size: Optional[int] = None) -> Dict[str, Any]:
query = """
query Materials($first: Int!) {
materials(first: $first) {
id
updatedAt
}
}
"""
return await cls._pull_resource("materials", query, page_size=page_size)
🤖 Prompt for AI Agents
In backend/services/hygraph_service.py around lines 133-194, the db parameter on
pull_materials, pull_modules, pull_systems, and pull_all is untyped which fails
mypy; add an explicit type annotation (e.g. db: Any or the concrete session type
you use) to each of those function signatures and ensure Any is imported from
typing (or replace Any with your session type), and apply the same annotation to
any other pull_* methods in the file so the mypy check passes.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

♻️ Duplicate comments (2)
backend/services/hygraph_service.py (2)

133-166: Annotate the db parameter to unblock mypy.

The db parameter lacks a type annotation, causing mypy failures. Add an explicit type annotation (e.g., Any or the concrete session type) to pull_materials, pull_modules, and pull_systems.

Apply this diff:

     @classmethod
-    async def pull_materials(cls, db, page_size: Optional[int] = None) -> Dict[str, Any]:
+    async def pull_materials(cls, db: Any, page_size: Optional[int] = None) -> Dict[str, Any]:
         query = """
         query Materials($first: Int!) {
             materials(first: $first) {
                 id
                 updatedAt
             }
         }
         """
         return await cls._pull_resource("materials", query, page_size=page_size)
 
     @classmethod
-    async def pull_modules(cls, db, page_size: Optional[int] = None) -> Dict[str, Any]:
+    async def pull_modules(cls, db: Any, page_size: Optional[int] = None) -> Dict[str, Any]:
         query = """
         query Modules($first: Int!) {
             modules(first: $first) {
                 id
                 updatedAt
             }
         }
         """
         return await cls._pull_resource("modules", query, page_size=page_size)
 
     @classmethod
-    async def pull_systems(cls, db, page_size: Optional[int] = None) -> Dict[str, Any]:
+    async def pull_systems(cls, db: Any, page_size: Optional[int] = None) -> Dict[str, Any]:
         query = """
         query Systems($first: Int!) {
             systems(first: $first) {
                 id
                 updatedAt
             }
         }
         """
         return await cls._pull_resource("systems", query, page_size=page_size)

Ensure Any is imported from typing at the top of the file.


169-169: Annotate the db parameter to unblock mypy.

The db parameter lacks a type annotation, causing mypy failures. Add an explicit type annotation (e.g., Any or the concrete session type).

Apply this diff:

     @classmethod
-    async def pull_all(cls, db, page_size: Optional[int] = None) -> Dict[str, Any]:
+    async def pull_all(cls, db: Any, page_size: Optional[int] = None) -> Dict[str, Any]:
📜 Review details

Configuration used: CodeRabbit UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b0b9129 and 8f09a21.

📒 Files selected for processing (2)
  • backend/services/cache.py (1 hunks)
  • backend/services/hygraph_service.py (2 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
backend/services/hygraph_service.py (4)
backend/api/config.py (1)
  • get_settings (66-69)
backend/services/cache.py (2)
  • RedisCache (25-87)
  • get (41-58)
backend/services/circuit_breaker.py (1)
  • CircuitOpenError (13-18)
backend/services/hygraph_client.py (1)
  • execute (32-65)
🪛 GitHub Actions: CI
backend/services/cache.py

[error] 12-12: Typos/lint issues detected by ruff/typos; possibly unused type-ignore comments or redefinition.

🔇 Additional comments (4)
backend/services/cache.py (3)

25-36: LGTM!

The initialization logic correctly handles the optional Redis dependency, falls back to environment variables for configuration, and sets up thread-safe in-memory storage. The lazy initialization pattern for the Redis client is appropriate.


41-58: Redis error handling now addresses past review concerns, but consider observability.

The try/except block correctly handles RedisError and falls back to the in-memory store, resolving the critical issue flagged in previous reviews. However, when Redis fails, the cache silently degrades with no logging or metrics, making it difficult for operators to detect and respond to Redis outages.

Consider adding logging when Redis becomes unavailable:

             try:
                 raw = self._client.get(namespaced)
             except RedisError:
+                # Log or emit metric: Redis unavailable, falling back to memory
                 self._client = None

Additionally, note that once _client is set to None, it never reconnects. If Redis has a transient issue, the cache will remain in memory-only mode for the lifetime of this instance. Depending on your deployment model, you may want to add a reconnection mechanism or rely on pod/process restarts.


77-87: LGTM with same observability note.

The clear method correctly implements the same Redis-first, memory-fallback pattern. The use of pop(namespaced, None) is safe and appropriate. As with get and set, consider adding logging when Redis becomes unavailable at line 83 for operational visibility.

backend/services/hygraph_service.py (1)

192-207: Fallback merging logic addresses the prior concern.

The logic at lines 192-207 attempts to preserve cached results for all resources when the circuit opens, which addresses the concern raised in the previous review. The implementation checks if each resource is already in the fallback, then tries successful results, then falls back to cache. While complex, this ensures all three resource types are included in the fallback response.

Note: The refactor suggested in the previous comment (replacing locals()) will make this logic clearer without changing its behavior.

Comment on lines 11 to 14
try: # pragma: no cover - optional dependency
import redis # type: ignore
except Exception: # pragma: no cover - optional dependency
redis = None # type: ignore[assignment]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Address the pipeline failure by refining the type-ignore comment.

The pipeline failure at line 12 indicates that the broad # type: ignore comment 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 with TYPE_CHECKING guards.

-try:  # pragma: no cover - optional dependency
-    import redis  # type: ignore
-except Exception:  # pragma: no cover - optional dependency
-    redis = None  # type: ignore[assignment]
+try:  # pragma: no cover - optional dependency
+    import redis
+except ImportError:  # pragma: no cover - optional dependency
+    redis = None  # type: ignore[assignment]

Note: Also consider catching only ImportError rather than the broad Exception for clarity.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try: # pragma: no cover - optional dependency
import redis # type: ignore
except Exception: # pragma: no cover - optional dependency
redis = None # type: ignore[assignment]
try: # pragma: no cover - optional dependency
import redis
except ImportError: # pragma: no cover - optional dependency
redis = None # type: ignore[assignment]
🧰 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
In backend/services/cache.py around lines 11 to 14, the import block uses a
broad Exception catch and an unspecific "# type: ignore" which triggered the
pipeline; change the except to catch ImportError only, and replace the blanket
"# type: ignore" on the import with a targeted suppression like "# type:
ignore[import-not-found]" (or remove it if you add a conditional TYPE_CHECKING
guard and proper typing), and keep the assignment to redis = None with a
specific "# type: ignore[assignment]" if mypy still complains.

Comment on lines 60 to 75
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:
try:
if ttl is not None:
self._client.setex(namespaced, ttl, payload)
else:
self._client.set(namespaced, payload)
except RedisError:
self._client = None
else:
return
expires_at = (time.time() + ttl) if ttl else None
with self._lock:
self._memory[namespaced] = (payload, expires_at)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Handle JSON serialization errors to prevent cache failures.

If value cannot be serialized to JSON (e.g., it contains non-serializable objects), line 61 will raise a TypeError or ValueError, causing the entire set operation to fail. Consider wrapping the serialization in a try/except to gracefully handle such cases.

 def set(self, key: str, value: Any, *, ttl: int | None = None) -> None:
-    payload = json.dumps(value)
+    try:
+        payload = json.dumps(value)
+    except (TypeError, ValueError) as e:
+        # Log the error or raise a custom exception
+        return  # or handle appropriately
     namespaced = self._ns(key)

Additionally, similar to the get method, consider adding logging when Redis fails at lines 69-70.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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:
try:
if ttl is not None:
self._client.setex(namespaced, ttl, payload)
else:
self._client.set(namespaced, payload)
except RedisError:
self._client = None
else:
return
expires_at = (time.time() + ttl) if ttl else None
with self._lock:
self._memory[namespaced] = (payload, expires_at)
def set(self, key: str, value: Any, *, ttl: int | None = None) -> None:
try:
payload = json.dumps(value)
except (TypeError, ValueError) as e:
# Log the error or raise a custom exception
return # or handle appropriately
namespaced = self._ns(key)
if self._client is not None:
try:
if ttl is not None:
self._client.setex(namespaced, ttl, payload)
else:
self._client.set(namespaced, payload)
except RedisError:
self._client = None
else:
return
expires_at = (time.time() + ttl) if ttl else None
with self._lock:
self._memory[namespaced] = (payload, expires_at)

Comment on lines 186 to 190
successful_results = {
"materials": locals().get("materials"),
"modules": locals().get("modules"),
"systems": locals().get("systems"),
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Replace locals() with explicit variable tracking.

Using locals() to capture successful results is fragile and non-Pythonic. The dictionary won't contain materials, modules, or systems if the exception is raised before they're assigned, and relies on implementation details of variable scoping.

Refactor to track results explicitly:

+        materials_result: Dict[str, Any] | None = None
+        modules_result: Dict[str, Any] | None = None
+        systems_result: Dict[str, Any] | None = None
+
         try:
-            materials = await cls.pull_materials(db, page_size=page_size)
-            modules = await cls.pull_modules(db, page_size=page_size)
-            systems = await cls.pull_systems(db, page_size=page_size)
+            materials_result = await cls.pull_materials(db, page_size=page_size)
+            modules_result = await cls.pull_modules(db, page_size=page_size)
+            systems_result = await cls.pull_systems(db, page_size=page_size)
         except CircuitOpenError as err:
             fallback_raw = err.fallback
             fallback: dict[str, dict[str, Any]] = {}
             if isinstance(fallback_raw, dict) and "resource" in fallback_raw:
                 resource_name = fallback_raw.get("resource")
                 if isinstance(resource_name, str):
                     fallback[resource_name] = fallback_raw
             elif isinstance(fallback_raw, dict):
                 for key, value in fallback_raw.items():
                     if isinstance(key, str) and isinstance(value, dict):
                         fallback[key] = value
 
             successful_results = {
-                "materials": locals().get("materials"),
-                "modules": locals().get("modules"),
-                "systems": locals().get("systems"),
+                "materials": materials_result,
+                "modules": modules_result,
+                "systems": systems_result,
             }
 
             for key in ("materials", "modules", "systems"):
                 if key in fallback and isinstance(fallback[key], dict):
                     continue
 
                 result = successful_results.get(key)
                 if isinstance(result, dict):
                     items = result.get("items")
                     if not isinstance(items, list):
                         items = []
                     fallback[key] = _fallback_envelope(key, items)
                     continue
 
                 cached_items = _CACHE.get(key) or []
                 if not isinstance(cached_items, list):
                     cached_items = []
                 fallback[key] = _fallback_envelope(key, cached_items)
 
             raise CircuitOpenError("Hygraph circuit open", fallback=fallback) from err
 
         return {
-            "materials": materials["processed"],
-            "modules": modules["processed"],
-            "systems": systems["processed"],
+            "materials": materials_result["processed"],
+            "modules": modules_result["processed"],
+            "systems": systems_result["processed"],
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
successful_results = {
"materials": locals().get("materials"),
"modules": locals().get("modules"),
"systems": locals().get("systems"),
}
materials_result: Dict[str, Any] | None = None
modules_result: Dict[str, Any] | None = None
systems_result: Dict[str, Any] | None = None
try:
materials_result = await cls.pull_materials(db, page_size=page_size)
modules_result = await cls.pull_modules(db, page_size=page_size)
systems_result = await cls.pull_systems(db, page_size=page_size)
except CircuitOpenError as err:
fallback_raw = err.fallback
fallback: dict[str, dict[str, Any]] = {}
if isinstance(fallback_raw, dict) and "resource" in fallback_raw:
resource_name = fallback_raw.get("resource")
if isinstance(resource_name, str):
fallback[resource_name] = fallback_raw
elif isinstance(fallback_raw, dict):
for key, value in fallback_raw.items():
if isinstance(key, str) and isinstance(value, dict):
fallback[key] = value
successful_results = {
"materials": materials_result,
"modules": modules_result,
"systems": systems_result,
}
for key in ("materials", "modules", "systems"):
if key in fallback and isinstance(fallback[key], dict):
continue
result = successful_results.get(key)
if isinstance(result, dict):
items = result.get("items")
if not isinstance(items, list):
items = []
fallback[key] = _fallback_envelope(key, items)
continue
cached_items = _CACHE.get(key) or []
if not isinstance(cached_items, list):
cached_items = []
fallback[key] = _fallback_envelope(key, cached_items)
raise CircuitOpenError("Hygraph circuit open", fallback=fallback) from err
return {
"materials": materials_result["processed"],
"modules": modules_result["processed"],
"systems": systems_result["processed"],
}
🤖 Prompt for AI Agents
In backend/services/hygraph_service.py around lines 186–190, the code uses
locals() to assemble successful_results which is fragile and can omit keys if an
exception occurs; instead explicitly track and assemble the results: ensure
materials, modules, and systems are declared/initialized (e.g., set to None)
before the try block or assigned as you compute them, and then build
successful_results = {"materials": materials, "modules": modules, "systems":
systems} (or populate keys only when values are available), removing the
locals() call.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Oct 18, 2025

Note

Docstrings generation - SUCCESS
Generated docstrings for this pull request at #283

coderabbitai bot added a commit that referenced this pull request Oct 18, 2025
…nt-m5f9cc`

Docstrings generation was requested by @shayancoin.

* #124 (comment)

The following files were modified:

* `backend/api/routes_sync.py`
* `backend/services/cache.py`
* `backend/services/circuit_breaker.py`
* `backend/services/hygraph_client.py`
* `backend/services/hygraph_service.py`
@shayancoin shayancoin merged commit dbd8d3b into main Oct 18, 2025
3 of 8 checks passed
shayancoin added a commit that referenced this pull request Oct 18, 2025
…nt-m5f9cc` (#283)

Docstrings generation was requested by @shayancoin.

* #124 (comment)

The following files were modified:

* `backend/api/routes_sync.py`
* `backend/services/cache.py`
* `backend/services/circuit_breaker.py`
* `backend/services/hygraph_client.py`
* `backend/services/hygraph_service.py`

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Shayan <shayan@coin.link>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant