Skip to content

Commit 43b602c

Browse files
committed
✨ Resume build log stream if interrupted
1 parent 46598eb commit 43b602c

File tree

6 files changed

+792
-57
lines changed

6 files changed

+792
-57
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.19.0

src/fastapi_cloud_cli/commands/deploy.py

Lines changed: 37 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import contextlib
2-
import json
32
import logging
43
import subprocess
54
import tarfile
@@ -8,7 +7,7 @@
87
from enum import Enum
98
from itertools import cycle
109
from pathlib import Path
11-
from typing import Any, Dict, Generator, List, Optional, Union
10+
from typing import Any, Dict, List, Optional, Union
1211

1312
import rignore
1413
import typer
@@ -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, BuildLogType
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
@@ -225,21 +224,11 @@ def _get_apps(team_id: str) -> List[AppResponse]:
225224
return [AppResponse.model_validate(app) for app in data]
226225

227226

228-
def _stream_build_logs(deployment_id: str) -> Generator[str, None, None]:
229-
with APIClient() as client:
230-
with client.stream(
231-
"GET", f"/deployments/{deployment_id}/build-logs", timeout=60
232-
) as response:
233-
response.raise_for_status()
234-
235-
yield from response.iter_lines()
236-
237-
238227
WAITING_MESSAGES = [
239228
"🚀 Preparing for liftoff! Almost there...",
240229
"👹 Sneaking past the dependency gremlins... Don't wake them up!",
241230
"🤏 Squishing code into a tiny digital sandwich. Nom nom nom.",
242-
"📉 Server space running low. Time to delete those cat videos?",
231+
"🐱 Removing cat videos from our servers to free up space.",
243232
"🐢 Uploading at blazing speeds of 1 byte per hour. Patience, young padawan.",
244233
"🔌 Connecting to server... Please stand by while we argue with the firewall.",
245234
"💥 Oops! We've angered the Python God. Sacrificing a rubber duck to appease it.",
@@ -350,43 +339,50 @@ def _wait_for_deployment(
350339
with toolkit.progress(
351340
next(messages), inline_logs=True, lines_to_show=20
352341
) as progress:
353-
with handle_http_errors(progress=progress):
354-
for line in _stream_build_logs(deployment.id):
355-
time_elapsed = time.monotonic() - started_at
342+
with APIClient() as client:
343+
try:
344+
for log in client.stream_build_logs(deployment.id):
345+
time_elapsed = time.monotonic() - started_at
356346

357-
data = json.loads(line)
347+
if log.type == BuildLogType.message and log.message:
348+
progress.log(Text.from_ansi(log.message.rstrip()))
358349

359-
if "message" in data:
360-
progress.log(Text.from_ansi(data["message"].rstrip()))
350+
if log.type == BuildLogType.complete:
351+
progress.log("")
352+
progress.log(
353+
f"🐔 Ready the chicken! Your app is ready at [link={deployment.url}]{deployment.url}[/link]"
354+
)
361355

362-
if data.get("type") == "complete":
363-
progress.log("")
364-
progress.log(
365-
f"🐔 Ready the chicken! Your app is ready at [link={deployment.url}]{deployment.url}[/link]"
366-
)
356+
progress.log("")
367357

368-
progress.log("")
358+
progress.log(
359+
f"You can also check the app logs at [link={deployment.dashboard_url}]{deployment.dashboard_url}[/link]"
360+
)
369361

370-
progress.log(
371-
f"You can also check the app logs at [link={deployment.dashboard_url}]{deployment.dashboard_url}[/link]"
372-
)
362+
break
373363

374-
break
364+
if log.type == BuildLogType.failed:
365+
progress.log("")
366+
progress.log(
367+
f"😔 Oh no! Something went wrong. Check out the logs at [link={deployment.dashboard_url}]{deployment.dashboard_url}[/link]"
368+
)
369+
raise typer.Exit(1)
375370

376-
if data.get("type") == "failed":
377-
progress.log("")
378-
progress.log(
379-
f"😔 Oh no! Something went wrong. Check out the logs at [link={deployment.dashboard_url}]{deployment.dashboard_url}[/link]"
380-
)
381-
raise typer.Exit(1)
371+
if time_elapsed > 30:
372+
messages = cycle(LONG_WAIT_MESSAGES)
382373

383-
if time_elapsed > 30:
384-
messages = cycle(LONG_WAIT_MESSAGES) # pragma: no cover
374+
if (time.monotonic() - last_message_changed_at) > 2:
375+
progress.title = next(messages)
385376

386-
if (time.monotonic() - last_message_changed_at) > 2:
387-
progress.title = next(messages) # pragma: no cover
377+
last_message_changed_at = time.monotonic()
388378

389-
last_message_changed_at = time.monotonic() # pragma: no cover
379+
except BuildLogError as e:
380+
logger.error("Build log streaming failed: %s", e)
381+
toolkit.print_line()
382+
toolkit.print(
383+
f"⚠️ Unable to stream build logs. Check the dashboard for status: [link={deployment.dashboard_url}]{deployment.dashboard_url}[/link]"
384+
)
385+
raise typer.Exit(1) from e
390386

391387

392388
class SignupToWaitingList(BaseModel):

src/fastapi_cloud_cli/utils/api.py

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,95 @@
1+
import logging
2+
import time
3+
from contextlib import AbstractContextManager, contextmanager
4+
from datetime import timedelta
5+
from enum import Enum
6+
from typing import Generator, Optional
7+
18
import httpx
9+
from pydantic import BaseModel, ValidationError
210

311
from fastapi_cloud_cli import __version__
412
from fastapi_cloud_cli.config import Settings
513
from fastapi_cloud_cli.utils.auth import get_auth_token
614

15+
logger = logging.getLogger(__name__)
16+
17+
BUILD_LOG_MAX_RETRIES = 3
18+
BUILD_LOG_TIMEOUT = timedelta(minutes=5)
19+
20+
21+
class BuildLogError(Exception): ...
22+
23+
24+
class BuildLogType(str, Enum):
25+
message = "message"
26+
complete = "complete"
27+
failed = "failed"
28+
timeout = "timeout" # Request closed, reconnect to continue
29+
heartbeat = "heartbeat" # Keepalive signal when no new logs
30+
31+
32+
class BuildLogLine(BaseModel):
33+
type: BuildLogType
34+
message: str | None = None
35+
id: str | None = None
36+
37+
38+
@contextmanager
39+
def attempt(attempt_number: int) -> Generator[None, None, None]:
40+
def _backoff() -> None:
41+
backoff_seconds = min(2**attempt_number, 30)
42+
logger.debug(
43+
"Retrying in %ds (attempt %d)",
44+
backoff_seconds,
45+
attempt_number,
46+
)
47+
time.sleep(backoff_seconds)
48+
49+
try:
50+
yield
51+
52+
except (
53+
httpx.TimeoutException,
54+
httpx.NetworkError,
55+
httpx.RemoteProtocolError,
56+
) as error:
57+
logger.debug("Network error (will retry): %s", error)
58+
59+
_backoff()
60+
61+
except httpx.HTTPStatusError as error:
62+
if error.response.status_code >= 500:
63+
logger.debug(
64+
"Server error %d (will retry): %s",
65+
error.response.status_code,
66+
error,
67+
)
68+
_backoff()
69+
else:
70+
# Try to get response text, but handle streaming responses gracefully
71+
try:
72+
error_detail = error.response.text
73+
except Exception:
74+
error_detail = "(response body unavailable)"
75+
raise BuildLogError(
76+
f"HTTP {error.response.status_code}: {error_detail}"
77+
) from error
78+
79+
80+
def attempts(
81+
total_attempts: int = 3, timeout: timedelta = timedelta(minutes=5)
82+
) -> Generator[AbstractContextManager[None], None, None]:
83+
start = time.monotonic()
84+
85+
for attempt_number in range(total_attempts):
86+
if time.monotonic() - start > timeout.total_seconds():
87+
raise TimeoutError(
88+
"Build log streaming timed out after %ds", timeout.total_seconds()
89+
)
90+
91+
yield attempt(attempt_number)
92+
793

894
class APIClient(httpx.Client):
995
def __init__(self) -> None:
@@ -19,3 +105,62 @@ def __init__(self) -> None:
19105
"User-Agent": f"fastapi-cloud-cli/{__version__}",
20106
},
21107
)
108+
109+
def stream_build_logs(
110+
self, deployment_id: str
111+
) -> Generator[BuildLogLine, None, None]:
112+
last_id = None
113+
114+
for attempt in attempts(BUILD_LOG_MAX_RETRIES, BUILD_LOG_TIMEOUT):
115+
with attempt:
116+
while True:
117+
params = {"last_id": last_id} if last_id else None
118+
119+
with self.stream(
120+
"GET",
121+
f"/deployments/{deployment_id}/build-logs",
122+
timeout=60,
123+
params=params,
124+
) as response:
125+
response.raise_for_status()
126+
127+
for line in response.iter_lines():
128+
if not line or not line.strip():
129+
continue
130+
131+
if log_line := self._parse_log_line(line):
132+
if log_line.id:
133+
last_id = log_line.id
134+
135+
if log_line.type == BuildLogType.message:
136+
yield log_line
137+
138+
if log_line.type in (
139+
BuildLogType.complete,
140+
BuildLogType.failed,
141+
):
142+
yield log_line
143+
144+
return
145+
146+
if log_line.type == BuildLogType.timeout:
147+
logger.debug("Received timeout; reconnecting")
148+
break # Breaks for loop to reconnect
149+
150+
else: # Only triggered if the for loop is not broken
151+
logger.debug(
152+
"Connection closed by server unexpectedly; attempting to reconnect"
153+
)
154+
break
155+
156+
time.sleep(0.5)
157+
158+
# Exhausted retries without getting any response
159+
raise BuildLogError(f"Failed after {BUILD_LOG_MAX_RETRIES} attempts")
160+
161+
def _parse_log_line(self, line: str) -> Optional[BuildLogLine]:
162+
try:
163+
return BuildLogLine.model_validate_json(line)
164+
except ValidationError as e:
165+
logger.debug("Skipping malformed log: %s (error: %s)", line[:100], e)
166+
return None

0 commit comments

Comments
 (0)