Skip to content

Commit 43366db

Browse files
buurropatrick91
andauthored
✨ Resume build log stream if interrupted (#109)
* ✨ Resume build log stream if interrupted * Downgrade `time-machine` module * Fix type annotations * Refactor * Add type for build log message * Move attempts logic to decorator * Use compat layer for pydantic 1 * Ignore things that can't be typed :') * Fix error --------- Co-authored-by: Patrick Arminio <patrick.arminio@gmail.com>
1 parent 2985b56 commit 43366db

File tree

7 files changed

+818
-42
lines changed

7 files changed

+818
-42
lines changed

requirements-tests.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ coverage[toml] >=6.2,<8.0
55
mypy ==1.14.1
66
ruff ==0.13.0
77
respx ==0.22.0
8+
time-machine ==2.15.0

src/fastapi_cloud_cli/commands/deploy.py

Lines changed: 21 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
import contextlib
2-
import json
32
import logging
43
import subprocess
54
import tempfile
65
import time
76
from enum import Enum
87
from itertools import cycle
98
from pathlib import Path
10-
from typing import Any, Dict, Generator, List, Optional, Union
9+
from typing import Any, Dict, List, Optional, Union
1110

1211
import fastar
1312
import rignore
@@ -20,7 +19,7 @@
2019
from typing_extensions import Annotated
2120

2221
from fastapi_cloud_cli.commands.login import login
23-
from fastapi_cloud_cli.utils.api import APIClient
22+
from fastapi_cloud_cli.utils.api import APIClient, BuildLogError, TooManyRetriesError
2423
from fastapi_cloud_cli.utils.apps import AppConfig, get_app_config, write_app_config
2524
from fastapi_cloud_cli.utils.auth import is_logged_in
2625
from fastapi_cloud_cli.utils.cli import get_rich_toolkit, handle_http_errors
@@ -239,21 +238,11 @@ def _get_apps(team_id: str) -> List[AppResponse]:
239238
return [model_validate(AppResponse, app) for app in data]
240239

241240

242-
def _stream_build_logs(deployment_id: str) -> Generator[str, None, None]:
243-
with APIClient() as client:
244-
with client.stream(
245-
"GET", f"/deployments/{deployment_id}/build-logs", timeout=60
246-
) as response:
247-
response.raise_for_status()
248-
249-
yield from response.iter_lines()
250-
251-
252241
WAITING_MESSAGES = [
253242
"🚀 Preparing for liftoff! Almost there...",
254243
"👹 Sneaking past the dependency gremlins... Don't wake them up!",
255244
"🤏 Squishing code into a tiny digital sandwich. Nom nom nom.",
256-
"📉 Server space running low. Time to delete those cat videos?",
245+
"🐱 Removing cat videos from our servers to free up space.",
257246
"🐢 Uploading at blazing speeds of 1 byte per hour. Patience, young padawan.",
258247
"🔌 Connecting to server... Please stand by while we argue with the firewall.",
259248
"💥 Oops! We've angered the Python God. Sacrificing a rubber duck to appease it.",
@@ -363,17 +352,15 @@ def _wait_for_deployment(
363352

364353
with toolkit.progress(
365354
next(messages), inline_logs=True, lines_to_show=20
366-
) as progress:
367-
with handle_http_errors(progress=progress):
368-
for line in _stream_build_logs(deployment.id):
355+
) as progress, APIClient() as client:
356+
try:
357+
for log in client.stream_build_logs(deployment.id):
369358
time_elapsed = time.monotonic() - started_at
370359

371-
data = json.loads(line)
360+
if log.type == "message":
361+
progress.log(Text.from_ansi(log.message.rstrip()))
372362

373-
if "message" in data:
374-
progress.log(Text.from_ansi(data["message"].rstrip()))
375-
376-
if data.get("type") == "complete":
363+
if log.type == "complete":
377364
progress.log("")
378365
progress.log(
379366
f"🐔 Ready the chicken! Your app is ready at [link={deployment.url}]{deployment.url}[/link]"
@@ -387,20 +374,28 @@ def _wait_for_deployment(
387374

388375
break
389376

390-
if data.get("type") == "failed":
377+
if log.type == "failed":
391378
progress.log("")
392379
progress.log(
393380
f"😔 Oh no! Something went wrong. Check out the logs at [link={deployment.dashboard_url}]{deployment.dashboard_url}[/link]"
394381
)
395382
raise typer.Exit(1)
396383

397384
if time_elapsed > 30:
398-
messages = cycle(LONG_WAIT_MESSAGES) # pragma: no cover
385+
messages = cycle(LONG_WAIT_MESSAGES)
399386

400387
if (time.monotonic() - last_message_changed_at) > 2:
401-
progress.title = next(messages) # pragma: no cover
388+
progress.title = next(messages)
389+
390+
last_message_changed_at = time.monotonic()
402391

403-
last_message_changed_at = time.monotonic() # pragma: no cover
392+
except (BuildLogError, TooManyRetriesError) as e:
393+
logger.error("Build log streaming failed: %s", e)
394+
toolkit.print_line()
395+
toolkit.print(
396+
f"⚠️ Unable to stream build logs. Check the dashboard for status: [link={deployment.dashboard_url}]{deployment.dashboard_url}[/link]"
397+
)
398+
raise typer.Exit(1) from e
404399

405400

406401
class SignupToWaitingList(BaseModel):

src/fastapi_cloud_cli/utils/api.py

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,133 @@
1+
import json
2+
import logging
3+
import time
4+
from contextlib import contextmanager
5+
from datetime import timedelta
6+
from functools import wraps
7+
from typing import (
8+
Callable,
9+
Generator,
10+
Literal,
11+
Optional,
12+
TypeVar,
13+
Union,
14+
)
15+
116
import httpx
17+
from pydantic import BaseModel, Field, ValidationError
18+
from typing_extensions import Annotated, ParamSpec
219

320
from fastapi_cloud_cli import __version__
421
from fastapi_cloud_cli.config import Settings
522
from fastapi_cloud_cli.utils.auth import get_auth_token
23+
from fastapi_cloud_cli.utils.pydantic_compat import TypeAdapter
24+
25+
logger = logging.getLogger(__name__)
26+
27+
BUILD_LOG_MAX_RETRIES = 3
28+
BUILD_LOG_TIMEOUT = timedelta(minutes=5)
29+
30+
31+
class BuildLogError(Exception):
32+
pass
33+
34+
35+
class TooManyRetriesError(Exception):
36+
pass
37+
38+
39+
class BuildLogLineGeneric(BaseModel):
40+
type: Literal["complete", "failed", "timeout", "heartbeat"]
41+
id: Optional[str] = None
42+
43+
44+
class BuildLogLineMessage(BaseModel):
45+
type: Literal["message"] = "message"
46+
message: str
47+
id: Optional[str] = None
48+
49+
50+
BuildLogLine = Union[BuildLogLineMessage, BuildLogLineGeneric]
51+
BuildLogAdapter = TypeAdapter[BuildLogLine](
52+
Annotated[BuildLogLine, Field(discriminator="type")] # type: ignore
53+
)
54+
55+
56+
@contextmanager
57+
def attempt(attempt_number: int) -> Generator[None, None, None]:
58+
def _backoff() -> None:
59+
backoff_seconds = min(2**attempt_number, 30)
60+
logger.debug(
61+
"Retrying in %ds (attempt %d)",
62+
backoff_seconds,
63+
attempt_number,
64+
)
65+
time.sleep(backoff_seconds)
66+
67+
try:
68+
yield
69+
70+
except (
71+
httpx.TimeoutException,
72+
httpx.NetworkError,
73+
httpx.RemoteProtocolError,
74+
) as error:
75+
logger.debug("Network error (will retry): %s", error)
76+
77+
_backoff()
78+
79+
except httpx.HTTPStatusError as error:
80+
if error.response.status_code >= 500:
81+
logger.debug(
82+
"Server error %d (will retry): %s",
83+
error.response.status_code,
84+
error,
85+
)
86+
_backoff()
87+
else:
88+
# Try to get response text, but handle streaming responses gracefully
89+
try:
90+
error_detail = error.response.text
91+
except Exception:
92+
error_detail = "(response body unavailable)"
93+
raise BuildLogError(
94+
f"HTTP {error.response.status_code}: {error_detail}"
95+
) from error
96+
97+
98+
P = ParamSpec("P")
99+
T = TypeVar("T")
100+
101+
102+
def attempts(
103+
total_attempts: int = 3, timeout: timedelta = timedelta(minutes=5)
104+
) -> Callable[
105+
[Callable[P, Generator[T, None, None]]], Callable[P, Generator[T, None, None]]
106+
]:
107+
def decorator(
108+
func: Callable[P, Generator[T, None, None]],
109+
) -> Callable[P, Generator[T, None, None]]:
110+
@wraps(func)
111+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> Generator[T, None, None]:
112+
start = time.monotonic()
113+
114+
for attempt_number in range(total_attempts):
115+
if time.monotonic() - start > timeout.total_seconds():
116+
raise TimeoutError(
117+
"Build log streaming timed out after %ds",
118+
timeout.total_seconds(),
119+
)
120+
121+
with attempt(attempt_number):
122+
yield from func(*args, **kwargs)
123+
# If we get here without exception, the generator completed successfully
124+
return
125+
126+
raise TooManyRetriesError(f"Failed after {total_attempts} attempts")
127+
128+
return wrapper
129+
130+
return decorator
6131

7132

8133
class APIClient(httpx.Client):
@@ -19,3 +144,52 @@ def __init__(self) -> None:
19144
"User-Agent": f"fastapi-cloud-cli/{__version__}",
20145
},
21146
)
147+
148+
@attempts(BUILD_LOG_MAX_RETRIES, BUILD_LOG_TIMEOUT)
149+
def stream_build_logs(
150+
self, deployment_id: str
151+
) -> Generator[BuildLogLine, None, None]:
152+
last_id = None
153+
154+
while True:
155+
params = {"last_id": last_id} if last_id else None
156+
157+
with self.stream(
158+
"GET",
159+
f"/deployments/{deployment_id}/build-logs",
160+
timeout=60,
161+
params=params,
162+
) as response:
163+
response.raise_for_status()
164+
165+
for line in response.iter_lines():
166+
if not line or not line.strip():
167+
continue
168+
169+
if log_line := self._parse_log_line(line):
170+
if log_line.id:
171+
last_id = log_line.id
172+
173+
if log_line.type == "message":
174+
yield log_line
175+
176+
if log_line.type in ("complete", "failed"):
177+
yield log_line
178+
return
179+
180+
if log_line.type == "timeout":
181+
logger.debug("Received timeout; reconnecting")
182+
break # Breaks for loop to reconnect
183+
else:
184+
logger.debug("Connection closed by server unexpectedly; will retry")
185+
186+
raise httpx.NetworkError("Connection closed without terminal state")
187+
188+
time.sleep(0.5)
189+
190+
def _parse_log_line(self, line: str) -> Optional[BuildLogLine]:
191+
try:
192+
return BuildLogAdapter.validate_json(line)
193+
except (ValidationError, json.JSONDecodeError) as e:
194+
logger.debug("Skipping malformed log: %s (error: %s)", line[:100], e)
195+
return None

src/fastapi_cloud_cli/utils/pydantic_compat.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,12 @@ def validate_python(self, value: Any) -> T:
6161
from pydantic import parse_obj_as
6262

6363
return parse_obj_as(self.type_, value) # type: ignore[no-any-return, unused-ignore]
64+
65+
def validate_json(self, value: str) -> T:
66+
"""Validate a JSON string against the type."""
67+
if PYDANTIC_V2:
68+
return self._adapter.validate_json(value) # type: ignore[no-any-return, union-attr, unused-ignore]
69+
else:
70+
from pydantic import parse_raw_as
71+
72+
return parse_raw_as(self.type_, value) # type: ignore[no-any-return, unused-ignore, operator]

0 commit comments

Comments
 (0)