Skip to content

Commit 33edb88

Browse files
committed
Move attempts logic to decorator
1 parent 2ecc498 commit 33edb88

File tree

3 files changed

+73
-49
lines changed

3 files changed

+73
-49
lines changed

src/fastapi_cloud_cli/commands/deploy.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from typing_extensions import Annotated
2020

2121
from fastapi_cloud_cli.commands.login import login
22-
from fastapi_cloud_cli.utils.api import APIClient, BuildLogError
22+
from fastapi_cloud_cli.utils.api import APIClient, BuildLogError, TooManyRetriesError
2323
from fastapi_cloud_cli.utils.apps import AppConfig, get_app_config, write_app_config
2424
from fastapi_cloud_cli.utils.auth import is_logged_in
2525
from fastapi_cloud_cli.utils.cli import get_rich_toolkit, handle_http_errors
@@ -375,7 +375,7 @@ def _wait_for_deployment(
375375

376376
last_message_changed_at = time.monotonic()
377377

378-
except BuildLogError as e:
378+
except (BuildLogError, TooManyRetriesError) as e:
379379
logger.error("Build log streaming failed: %s", e)
380380
toolkit.print_line()
381381
toolkit.print(

src/fastapi_cloud_cli/utils/api.py

Lines changed: 67 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,16 @@
22
import time
33
from contextlib import contextmanager
44
from datetime import timedelta
5-
from typing import ContextManager, Generator, Literal, Optional, Union
5+
from functools import wraps
6+
from typing import (
7+
Callable,
8+
Generator,
9+
Literal,
10+
Optional,
11+
ParamSpec,
12+
TypeVar,
13+
Union,
14+
)
615

716
import httpx
817
from pydantic import BaseModel, Field, TypeAdapter, ValidationError
@@ -22,6 +31,10 @@ class BuildLogError(Exception):
2231
pass
2332

2433

34+
class TooManyRetriesError(Exception):
35+
pass
36+
37+
2538
class BuildLogLineGeneric(BaseModel):
2639
type: Literal["complete", "failed", "timeout", "heartbeat"]
2740
id: Optional[str] = None
@@ -81,18 +94,35 @@ def _backoff() -> None:
8194
) from error
8295

8396

97+
P = ParamSpec("P")
98+
T = TypeVar("T")
99+
100+
84101
def attempts(
85102
total_attempts: int = 3, timeout: timedelta = timedelta(minutes=5)
86-
) -> Generator[ContextManager[None], None, None]:
87-
start = time.monotonic()
103+
) -> Callable[[Callable[P, Generator[T]]], Callable[P, Generator[T]]]:
104+
def decorator(func: Callable[P, Generator[T]]) -> Callable[P, Generator[T]]:
105+
@wraps(func)
106+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> Generator[T, None, None]:
107+
start = time.monotonic()
88108

89-
for attempt_number in range(total_attempts):
90-
if time.monotonic() - start > timeout.total_seconds():
91-
raise TimeoutError(
92-
"Build log streaming timed out after %ds", timeout.total_seconds()
93-
)
109+
for attempt_number in range(total_attempts):
110+
if time.monotonic() - start > timeout.total_seconds():
111+
raise TimeoutError(
112+
"Build log streaming timed out after %ds",
113+
timeout.total_seconds(),
114+
)
115+
116+
with attempt(attempt_number):
117+
yield from func(*args, **kwargs)
118+
# If we get here without exception, the generator completed successfully
119+
return
120+
121+
raise TooManyRetriesError(f"Failed after {total_attempts} attempts")
94122

95-
yield attempt(attempt_number)
123+
return wrapper
124+
125+
return decorator
96126

97127

98128
class APIClient(httpx.Client):
@@ -110,54 +140,47 @@ def __init__(self) -> None:
110140
},
111141
)
112142

143+
@attempts(BUILD_LOG_MAX_RETRIES, BUILD_LOG_TIMEOUT)
113144
def stream_build_logs(
114145
self, deployment_id: str
115146
) -> Generator[BuildLogLine, None, None]:
116147
last_id = None
117148

118-
for attempt in attempts(BUILD_LOG_MAX_RETRIES, BUILD_LOG_TIMEOUT):
119-
with attempt:
120-
while True:
121-
params = {"last_id": last_id} if last_id else None
122-
123-
with self.stream(
124-
"GET",
125-
f"/deployments/{deployment_id}/build-logs",
126-
timeout=60,
127-
params=params,
128-
) as response:
129-
response.raise_for_status()
130-
131-
for line in response.iter_lines():
132-
if not line or not line.strip():
133-
continue
149+
while True:
150+
params = {"last_id": last_id} if last_id else None
134151

135-
if log_line := self._parse_log_line(line):
136-
if log_line.id:
137-
last_id = log_line.id
152+
with self.stream(
153+
"GET",
154+
f"/deployments/{deployment_id}/build-logs",
155+
timeout=60,
156+
params=params,
157+
) as response:
158+
response.raise_for_status()
138159

139-
if log_line.type == "message":
140-
yield log_line
160+
for line in response.iter_lines():
161+
if not line or not line.strip():
162+
continue
141163

142-
if log_line.type in ("complete", "failed"):
143-
yield log_line
164+
if log_line := self._parse_log_line(line):
165+
if log_line.id:
166+
last_id = log_line.id
144167

145-
return
168+
if log_line.type == "message":
169+
yield log_line
146170

147-
if log_line.type == "timeout":
148-
logger.debug("Received timeout; reconnecting")
149-
break # Breaks for loop to reconnect
171+
if log_line.type in ("complete", "failed"):
172+
yield log_line
173+
return
150174

151-
else: # Only triggered if the for loop is not broken
152-
logger.debug(
153-
"Connection closed by server unexpectedly; attempting to reconnect"
154-
)
155-
break
175+
if log_line.type == "timeout":
176+
logger.debug("Received timeout; reconnecting")
177+
break # Breaks for loop to reconnect
178+
else:
179+
logger.debug("Connection closed by server unexpectedly; will retry")
156180

157-
time.sleep(0.5)
181+
raise httpx.NetworkError("Connection closed without terminal state")
158182

159-
# Exhausted retries without getting any response
160-
raise BuildLogError(f"Failed after {BUILD_LOG_MAX_RETRIES} attempts")
183+
time.sleep(0.5)
161184

162185
def _parse_log_line(self, line: str) -> Optional[BuildLogLine]:
163186
try:

tests/test_api_client.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
APIClient,
1414
BuildLogError,
1515
BuildLogLineMessage,
16+
TooManyRetriesError,
1617
)
1718
from tests.utils import build_logs_response
1819

@@ -254,7 +255,7 @@ def test_stream_build_logs_max_retries_exceeded(
254255

255256
with patch("time.sleep"):
256257
with pytest.raises(
257-
BuildLogError, match=f"Failed after {BUILD_LOG_MAX_RETRIES} attempts"
258+
TooManyRetriesError, match=f"Failed after {BUILD_LOG_MAX_RETRIES} attempts"
258259
):
259260
list(client.stream_build_logs(deployment_id))
260261

@@ -341,7 +342,7 @@ def test_stream_build_logs_connection_closed_without_complete_failed_or_timeout(
341342

342343
logs = client.stream_build_logs(deployment_id)
343344

344-
with pytest.raises(BuildLogError, match="Failed after"):
345+
with patch("time.sleep"), pytest.raises(TooManyRetriesError, match="Failed after"):
345346
for _ in range(BUILD_LOG_MAX_RETRIES + 1):
346347
next(logs)
347348

@@ -367,5 +368,5 @@ def responses(request: httpx.Request, route: respx.Route) -> Response:
367368

368369
logs_route.mock(side_effect=responses)
369370

370-
with pytest.raises(TimeoutError, match="timed out"):
371+
with patch("time.sleep"), pytest.raises(TimeoutError, match="timed out"):
371372
list(client.stream_build_logs(deployment_id))

0 commit comments

Comments
 (0)