Skip to content

Commit f5bcb9c

Browse files
📝 Add docstrings to codex/implement-circuit-breaker-and-hygraph-client (#274)
Docstrings generation was requested by @shayancoin. * #108 (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>
1 parent bcbcf8e commit f5bcb9c

File tree

5 files changed

+328
-10
lines changed

5 files changed

+328
-10
lines changed

‎backend/api/routes_sync.py‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -178,10 +178,24 @@ async def hygraph_pull(
178178
db: Session = Depends(get_db),
179179
) -> Dict[str, Any]:
180180
"""
181-
Admin pull:
182-
- Auth via Bearer token (constant-time compare)
183-
- Accepts "type" or "sync_type" + optional "page_size"
184-
- Validates positive page_size and caps inside service (≤200)
181+
Trigger an administrative Hygraph synchronization for the requested resource type.
182+
183+
Accepts a JSON body with either "type" or "sync_type" (case-insensitive) set to one of: "materials", "modules", "systems", or "all". Optionally accepts "page_size" as a positive integer to request a specific page size (service enforces its own maximum).
184+
185+
Parameters:
186+
body (dict): Request body that may contain:
187+
- "type" or "sync_type": the resource sync type to run.
188+
- "page_size" (optional): positive integer for page size.
189+
db (Session): Database session (injected).
190+
191+
Returns:
192+
dict: {"ok": True, "data": counts} where `counts` maps resource types to processed counts.
193+
If a circuit-breaker fallback is used, returns {"ok": True, "data": fallback_result, "cached": True}.
194+
195+
Raises:
196+
HTTPException (400) if "page_size" is not a positive integer or if the type is unsupported.
197+
HTTPException (503) if the Hygraph circuit breaker is open and no fallback is available.
198+
HTTPException (500) on internal sync failures.
185199
"""
186200
sync_type = str((body.get("type") or body.get("sync_type") or "")).lower().strip()
187201
page_size_raw = body.get("page_size")

‎backend/services/cache.py‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,16 +24,38 @@ class RedisCache:
2424
_memory_store: Dict[str, tuple[Any, Optional[float]]] = field(default_factory=dict, init=False, repr=False)
2525

2626
def __post_init__(self) -> None:
27+
"""
28+
Initialize the Redis client for this instance if the asyncio Redis library is available.
29+
30+
If the optional asyncio Redis package is present, attempts to create a client from the configured URL with response decoding enabled and stores it on `self._client`; if client creation fails or the package is unavailable, `self._client` is left as `None`.
31+
"""
2732
if redis_asyncio is not None:
2833
try:
2934
self._client = redis_asyncio.from_url(self.url, decode_responses=True)
3035
except Exception:
3136
self._client = None
3237

3338
def _prefixed(self, key: str) -> str:
39+
"""
40+
Apply the namespace prefix to a key when a namespace is configured.
41+
42+
Parameters:
43+
key (str): The key to which the namespace prefix should be applied.
44+
45+
Returns:
46+
str: The namespaced key in the form `namespace:key` if `namespace` is non-empty, otherwise the original `key`.
47+
"""
3448
return f"{self.namespace}:{key}" if self.namespace else key
3549

3650
async def get_json(self, key: str) -> Any | None:
51+
"""
52+
Retrieve a JSON-decoded value for a namespaced key, preferring Redis and falling back to the in-memory store when configured.
53+
54+
If a value is found in Redis but cannot be JSON-decoded, this method returns `None`. When using the in-memory fallback, expired entries are removed on access.
55+
56+
Returns:
57+
The decoded Python value if present and valid, `None` otherwise.
58+
"""
3759
full_key = self._prefixed(key)
3860
if self._client is not None:
3961
try:
@@ -57,6 +79,14 @@ async def get_json(self, key: str) -> Any | None:
5779
return value
5880

5981
async def set_json(self, key: str, value: Any, *, ttl: Optional[int] = None) -> None:
82+
"""
83+
Store a JSON-serializable value under the namespaced key, persisting to Redis and optionally to the in-memory fallback with an optional TTL.
84+
85+
Parameters:
86+
key (str): The cache key (namespace will be applied).
87+
value (Any): JSON-serializable value to store.
88+
ttl (Optional[int]): Time-to-live in seconds; when provided, the in-memory fallback will expire the entry after this many seconds (relative to the current monotonic clock).
89+
"""
6090
full_key = self._prefixed(key)
6191
dumped = json.dumps(value)
6292
if self._client is not None:
@@ -70,4 +100,4 @@ async def set_json(self, key: str, value: Any, *, ttl: Optional[int] = None) ->
70100
expires_at = None
71101
if ttl is not None:
72102
expires_at = time.monotonic() + ttl
73-
self._memory_store[full_key] = (value, expires_at)
103+
self._memory_store[full_key] = (value, expires_at)

‎backend/services/circuit_breaker.py‎

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ class CircuitOpenError(RuntimeError):
1414
"""Raised when the circuit breaker is OPEN and calls are blocked."""
1515

1616
def __init__(self, message: str, fallback_result: Any | None = None) -> None:
17+
"""
18+
Initialize the CircuitOpenError with an error message and an optional fallback result.
19+
20+
Parameters:
21+
message (str): Human-readable error message describing why the circuit is open.
22+
fallback_result (Any | None): Optional value to be used as a fallback when the circuit blocks calls; stored on the exception as `fallback_result`.
23+
"""
1724
super().__init__(message)
1825
self.fallback_result = fallback_result
1926

@@ -36,29 +43,57 @@ class CircuitBreaker:
3643
HALF_OPEN: str = "HALF_OPEN"
3744

3845
def _transition_to_open(self) -> None:
46+
"""
47+
Transition the circuit to OPEN state and reset internal counters.
48+
49+
Sets the circuit state to OPEN, records the open timestamp from the configured time provider, and resets both failure_count and success_count to 0.
50+
"""
3951
self.state = self.OPEN
4052
self.opened_at = self.time_provider()
4153
self.failure_count = 0
4254
self.success_count = 0
4355

4456
def _transition_to_half_open(self) -> None:
57+
"""
58+
Transition the circuit to HALF_OPEN and reset timing and counters.
59+
60+
Sets the breaker state to HALF_OPEN, clears the recorded open timestamp, and resets both failure and success counters to zero.
61+
"""
4562
self.state = self.HALF_OPEN
4663
self.opened_at = None
4764
self.failure_count = 0
4865
self.success_count = 0
4966

5067
def _transition_to_closed(self) -> None:
68+
"""
69+
Transition the circuit to CLOSED and reset timing and counters.
70+
71+
Sets the breaker state to CLOSED, clears `opened_at`, and resets `failure_count` and `success_count` to 0 so the circuit treats subsequent calls as healthy.
72+
"""
5173
self.state = self.CLOSED
5274
self.opened_at = None
5375
self.failure_count = 0
5476
self.success_count = 0
5577

5678
def _ready_for_half_open(self) -> bool:
79+
"""
80+
Determine whether the circuit has been open long enough to move to HALF_OPEN.
81+
82+
Returns:
83+
True if `opened_at` is set and the elapsed time since it was set is greater than or equal to `recovery_timeout`, False otherwise.
84+
"""
5785
if self.opened_at is None:
5886
return False
5987
return (self.time_provider() - self.opened_at) >= self.recovery_timeout
6088

6189
def _record_failure(self) -> None:
90+
"""
91+
Record a failure and update the circuit breaker state accordingly.
92+
93+
If the circuit is HALF_OPEN, transition it to OPEN immediately. Otherwise,
94+
increment the consecutive failure count and transition to OPEN when that
95+
count reaches or exceeds the configured failure_threshold.
96+
"""
6297
if self.state == self.HALF_OPEN:
6398
self._transition_to_open()
6499
return
@@ -67,6 +102,13 @@ def _record_failure(self) -> None:
67102
self._transition_to_open()
68103

69104
def _record_success(self) -> None:
105+
"""
106+
Record a successful call and update the circuit breaker state.
107+
108+
If the circuit is HALF_OPEN, increments the consecutive success counter and closes the circuit when the
109+
count reaches or exceeds `half_open_success_threshold`. If the circuit is OPEN (unexpected), transitions
110+
proactively to CLOSED. Otherwise (CLOSED), resets the consecutive failure counter to zero.
111+
"""
70112
if self.state == self.HALF_OPEN:
71113
self.success_count += 1
72114
if self.success_count >= self.half_open_success_threshold:
@@ -85,7 +127,31 @@ def call(
85127
fallback: Optional[Callable[[], T]] = None,
86128
**kwargs: Any,
87129
) -> T:
88-
"""Execute ``func`` guarding access through the circuit breaker."""
130+
"""
131+
Guard execution of a callable using the circuit breaker and return its result.
132+
133+
If the circuit is OPEN and not yet ready for HALF_OPEN, this method invokes the optional
134+
`fallback` to obtain a fallback result and raises CircuitOpenError carrying that result.
135+
If a call executed while in HALF_OPEN raises an exception, the circuit transitions to OPEN,
136+
the optional `fallback` is invoked to obtain a fallback result, and a CircuitOpenError is
137+
raised chained to the original exception. Exceptions raised outside HALF_OPEN are re-raised.
138+
139+
Parameters:
140+
func (Callable[..., T]): The callable to invoke when the circuit allows execution.
141+
*args: Positional arguments forwarded to `func`.
142+
fallback (Optional[Callable[[], T]]): Callable producing a fallback result when the
143+
circuit blocks execution or when a HALF_OPEN call fails. If not provided, the
144+
CircuitOpenError will carry `None` as its `fallback_result`.
145+
**kwargs: Keyword arguments forwarded to `func`.
146+
147+
Returns:
148+
T: The result returned by `func` when execution succeeds.
149+
150+
Raises:
151+
CircuitOpenError: When the circuit is OPEN (and not ready for HALF_OPEN) or when a
152+
HALF_OPEN call fails; contains the `fallback_result` returned by `fallback` (or
153+
`None` if no fallback was provided).
154+
"""
89155

90156
if self.state == self.OPEN:
91157
if self._ready_for_half_open():
@@ -105,4 +171,4 @@ def call(
105171
raise
106172
else:
107173
self._record_success()
108-
return result
174+
return result

‎backend/services/hygraph_client.py‎

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ class HygraphGraphQLError(RuntimeError):
1212
"""Raised when Hygraph returns GraphQL errors."""
1313

1414
def __init__(self, errors: Any) -> None:
15+
"""
16+
Create a HygraphGraphQLError that wraps a GraphQL `errors` payload.
17+
18+
Parameters:
19+
errors: The GraphQL `errors` payload returned by Hygraph (commonly a list of error objects or a dict). Stored on the exception instance as the `errors` attribute.
20+
"""
1521
super().__init__("Hygraph GraphQL error")
1622
self.errors = errors
1723

@@ -28,6 +34,19 @@ def __init__(
2834
breaker: Optional[CircuitBreaker] = None,
2935
client: Optional[httpx.Client] = None,
3036
) -> None:
37+
"""
38+
Initialize the Hygraph client with the GraphQL endpoint, optional authentication, HTTP client, and circuit breaker.
39+
40+
Parameters:
41+
endpoint (str): Hygraph GraphQL endpoint URL; must be non-empty.
42+
token (str): Optional bearer token; when provided, an Authorization header is set.
43+
timeout (float): Timeout (seconds) for the created HTTPX client when no client is supplied.
44+
breaker (Optional[CircuitBreaker]): Circuit breaker instance to use; a default CircuitBreaker is created if omitted.
45+
client (Optional[httpx.Client]): Preconfigured HTTPX client to use instead of creating one.
46+
47+
Raises:
48+
ValueError: If `endpoint` is empty or falsy.
49+
"""
3150
if not endpoint:
3251
raise ValueError("Hygraph endpoint must be configured")
3352
self._endpoint = endpoint
@@ -41,6 +60,12 @@ def __init__(
4160

4261
@property
4362
def breaker(self) -> CircuitBreaker:
63+
"""
64+
Expose the CircuitBreaker instance used by this client.
65+
66+
Returns:
67+
CircuitBreaker: The circuit breaker used to protect and control execution of remote requests.
68+
"""
4469
return self._breaker
4570

4671
def execute(
@@ -50,9 +75,33 @@ def execute(
5075
*,
5176
fallback: Optional[Callable[[], Any]] = None,
5277
) -> Dict[str, Any]:
53-
"""Run the GraphQL ``query`` through the circuit breaker."""
78+
"""
79+
Execute the given GraphQL query via the client's circuit breaker and return the response data.
80+
81+
Runs the query against the configured Hygraph endpoint, raising HygraphGraphQLError if the GraphQL response contains errors. If the response contains a `data` object it is returned as a dict; if `data` is missing or not a dict, an empty dict is returned. If the underlying circuit breaker is open, the CircuitOpenError raised by the breaker is propagated so callers can apply their fallback handling.
82+
83+
Parameters:
84+
query: The GraphQL query string to execute.
85+
variables: Optional mapping of variables to include with the query.
86+
fallback: Optional callable to be used by the circuit breaker when executing the request.
87+
88+
Returns:
89+
dict: The `data` payload from the GraphQL response, or an empty dict if no valid data is present.
90+
91+
Raises:
92+
HygraphGraphQLError: If the GraphQL response contains an `errors` payload.
93+
CircuitOpenError: If the circuit breaker is open and rejects execution.
94+
"""
5495

5596
def _request() -> Dict[str, Any]:
97+
"""
98+
Perform the GraphQL POST to the configured Hygraph endpoint and return the `data` payload.
99+
100+
Sends the provided `query` and `variables` as JSON, raises `HygraphGraphQLError` when the response contains GraphQL errors, and returns the `data` object from the response as a dict. If `data` is missing or not a dict, returns an empty dict.
101+
102+
Returns:
103+
Dict[str, Any]: The `data` object from the GraphQL response, or an empty dict if absent or malformed.
104+
"""
56105
response = self._client.post(
57106
self._endpoint,
58107
json={"query": query, "variables": variables or {}},
@@ -74,4 +123,9 @@ def _request() -> Dict[str, Any]:
74123
raise
75124

76125
def close(self) -> None:
77-
self._client.close()
126+
"""
127+
Close the Hygraph client's underlying HTTP connection pool.
128+
129+
Closes the internal httpx.Client to release network resources and sockets.
130+
"""
131+
self._client.close()

0 commit comments

Comments
 (0)