Skip to content

Commit e839475

Browse files
Refactor to use attempts
1 parent f7a9058 commit e839475

File tree

6 files changed

+111
-208
lines changed

6 files changed

+111
-208
lines changed

src/fastapi_cloud_cli/commands/deploy.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from rich_toolkit.menu import Option
2020

2121
from fastapi_cloud_cli.commands.login import login
22-
from fastapi_cloud_cli.utils.api import APIClient, BuildLogError, TooManyRetriesError
22+
from fastapi_cloud_cli.utils.api import APIClient, StreamLogError, 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
@@ -399,7 +399,7 @@ def _wait_for_deployment(
399399

400400
last_message_changed_at = time.monotonic()
401401

402-
except (BuildLogError, TooManyRetriesError, TimeoutError) as e:
402+
except (StreamLogError, TooManyRetriesError, TimeoutError) as e:
403403
progress.set_error(
404404
dedent(f"""
405405
[error]Build log streaming failed: {e}[/]
@@ -408,7 +408,7 @@ def _wait_for_deployment(
408408
""").strip()
409409
)
410410

411-
raise typer.Exit(1) from e
411+
raise typer.Exit(1) from None
412412

413413

414414
class SignupToWaitingList(BaseModel):

src/fastapi_cloud_cli/commands/logs.py

Lines changed: 45 additions & 131 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,25 @@
1-
import json
21
import logging
3-
import time
4-
from collections.abc import Generator
52
from datetime import datetime
63
from pathlib import Path
74
from typing import Annotated, Optional
85

96
import typer
10-
from httpx import HTTPError, HTTPStatusError, ReadTimeout
11-
from pydantic import BaseModel, ValidationError
127
from rich.markup import escape
138
from rich_toolkit import RichToolkit
149

15-
from fastapi_cloud_cli.utils.api import APIClient
10+
from fastapi_cloud_cli.utils.api import (
11+
APIClient,
12+
AppLogEntry,
13+
StreamLogError,
14+
TooManyRetriesError,
15+
)
1616
from fastapi_cloud_cli.utils.apps import AppConfig, get_app_config
1717
from fastapi_cloud_cli.utils.auth import is_logged_in
1818
from fastapi_cloud_cli.utils.cli import get_rich_toolkit
1919

2020
logger = logging.getLogger(__name__)
2121

22-
MAX_RECONNECT_ATTEMPTS = 10
23-
RECONNECT_DELAY_SECONDS = 1
22+
2423
LOG_LEVEL_COLORS = {
2524
"debug": "blue",
2625
"info": "cyan",
@@ -32,38 +31,11 @@
3231
}
3332

3433

35-
class LogEntry(BaseModel):
36-
timestamp: datetime
37-
message: str
38-
level: str = "unknown"
39-
40-
41-
def _stream_logs(
42-
app_id: str,
43-
tail: int,
44-
since: str,
45-
follow: bool,
46-
) -> Generator[str, None, None]:
47-
with APIClient() as client:
48-
timeout = 120 if follow else 30
49-
with client.stream(
50-
"GET",
51-
f"/apps/{app_id}/logs/stream",
52-
params={
53-
"tail": tail,
54-
"since": since,
55-
"follow": follow,
56-
},
57-
timeout=timeout,
58-
) as response:
59-
response.raise_for_status()
60-
61-
yield from response.iter_lines()
62-
63-
64-
def _format_log_line(log: LogEntry) -> str:
34+
def _format_log_line(log: AppLogEntry) -> str:
6535
"""Format a log entry for display with a colored indicator"""
66-
timestamp_str = log.timestamp.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
36+
# Parse the timestamp string to format it consistently
37+
timestamp = datetime.fromisoformat(log.timestamp.replace("Z", "+00:00"))
38+
timestamp_str = timestamp.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
6739
color = LOG_LEVEL_COLORS.get(log.level.lower())
6840

6941
message = escape(log.message)
@@ -81,104 +53,46 @@ def _process_log_stream(
8153
since: str,
8254
follow: bool,
8355
) -> None:
56+
"""Stream app logs and print them to the console."""
8457
log_count = 0
85-
last_timestamp: datetime | None = None
86-
current_since = since
87-
current_tail = tail
88-
reconnect_attempts = 0
89-
90-
while True:
91-
try:
92-
for line in _stream_logs(
58+
59+
try:
60+
with APIClient() as client:
61+
for log in client.stream_app_logs(
9362
app_id=app_config.app_id,
94-
tail=current_tail,
95-
since=current_since,
63+
tail=tail,
64+
since=since,
9665
follow=follow,
9766
):
98-
if not line: # pragma: no cover
99-
continue
100-
101-
try:
102-
data = json.loads(line)
103-
except json.JSONDecodeError:
104-
logger.debug("Failed to parse log line: %s", line)
105-
continue
106-
107-
# Skip heartbeat messages
108-
if data.get("type") == "heartbeat": # pragma: no cover
109-
continue
110-
111-
if data.get("type") == "error":
112-
toolkit.print(
113-
f"Error: {data.get('message', 'Unknown error')}",
114-
)
115-
raise typer.Exit(1)
116-
117-
# Parse and display log entry
118-
try:
119-
log_entry = LogEntry.model_validate(data)
120-
toolkit.print(_format_log_line(log_entry))
121-
log_count += 1
122-
last_timestamp = log_entry.timestamp
123-
# Reset reconnect attempts on successful log receipt
124-
reconnect_attempts = 0
125-
except ValidationError as e: # pragma: no cover
126-
logger.debug("Failed to parse log entry: %s - %s", data, e)
127-
continue
128-
129-
# Stream ended normally (only happens with --no-follow)
67+
toolkit.print(_format_log_line(log))
68+
log_count += 1
69+
13070
if not follow and log_count == 0:
13171
toolkit.print("No logs found for the specified time range.")
132-
break
133-
134-
except KeyboardInterrupt: # pragma: no cover
135-
toolkit.print_line()
136-
break
137-
except (ReadTimeout, HTTPError) as e:
138-
# In follow mode, try to reconnect on connection issues
139-
if follow and not isinstance(e, HTTPStatusError):
140-
reconnect_attempts += 1
141-
if reconnect_attempts >= MAX_RECONNECT_ATTEMPTS:
142-
toolkit.print(
143-
"Lost connection to log stream. Please try again later.",
144-
)
145-
raise typer.Exit(1) from None
146-
147-
logger.debug(
148-
"Connection lost, reconnecting (attempt %d/%d)...",
149-
reconnect_attempts,
150-
MAX_RECONNECT_ATTEMPTS,
151-
)
152-
153-
# On reconnect, resume from last seen timestamp
154-
# The API uses strict > comparison, so logs with the same timestamp
155-
# as last_timestamp will be filtered out (no duplicates)
156-
if last_timestamp: # pragma: no cover
157-
current_since = last_timestamp.isoformat()
158-
current_tail = 0 # Don't fetch historical logs again
159-
160-
time.sleep(RECONNECT_DELAY_SECONDS)
161-
continue
162-
163-
if isinstance(e, HTTPStatusError) and e.response.status_code in (401, 403):
164-
toolkit.print(
165-
"The specified token is not valid. Use [blue]`fastapi login`[/] to generate a new token.",
166-
)
167-
if isinstance(e, HTTPStatusError) and e.response.status_code == 404:
168-
toolkit.print(
169-
"App not found. Make sure to use the correct account.",
170-
)
171-
elif isinstance(e, ReadTimeout):
172-
toolkit.print(
173-
"The request timed out. Please try again later.",
174-
)
175-
else:
176-
logger.exception("Failed to fetch logs")
177-
178-
toolkit.print(
179-
"Failed to fetch logs. Please try again later.",
180-
)
181-
raise typer.Exit(1) from None
72+
return
73+
except KeyboardInterrupt: # pragma: no cover
74+
toolkit.print_line()
75+
return
76+
except StreamLogError as e:
77+
error_msg = str(e)
78+
if "HTTP 401" in error_msg or "HTTP 403" in error_msg:
79+
toolkit.print(
80+
"The specified token is not valid. Use [blue]`fastapi login`[/] to generate a new token.",
81+
)
82+
elif "HTTP 404" in error_msg:
83+
toolkit.print(
84+
"App not found. Make sure to use the correct account.",
85+
)
86+
else:
87+
toolkit.print(
88+
f"[red]Error:[/] {escape(error_msg)}",
89+
)
90+
raise typer.Exit(1) from None
91+
except (TooManyRetriesError, TimeoutError):
92+
toolkit.print(
93+
"Lost connection to log stream. Please try again later.",
94+
)
95+
raise typer.Exit(1) from None
18296

18397

18498
def logs(

src/fastapi_cloud_cli/utils/api.py

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,18 +24,26 @@
2424

2525
logger = logging.getLogger(__name__)
2626

27-
BUILD_LOG_MAX_RETRIES = 3
28-
BUILD_LOG_TIMEOUT = timedelta(minutes=5)
27+
STREAM_LOGS_MAX_RETRIES = 3
28+
STREAM_LOGS_TIMEOUT = timedelta(minutes=5)
2929

3030

31-
class BuildLogError(Exception):
31+
class StreamLogError(Exception):
32+
"""Raised when there's an error streaming logs (build or app logs)."""
33+
3234
pass
3335

3436

3537
class TooManyRetriesError(Exception):
3638
pass
3739

3840

41+
class AppLogEntry(BaseModel):
42+
timestamp: str
43+
message: str
44+
level: str
45+
46+
3947
class BuildLogLineGeneric(BaseModel):
4048
type: Literal["complete", "failed", "timeout", "heartbeat"]
4149
id: Optional[str] = None
@@ -90,7 +98,7 @@ def _backoff() -> None:
9098
error_detail = error.response.text
9199
except Exception:
92100
error_detail = "(response body unavailable)"
93-
raise BuildLogError(
101+
raise StreamLogError(
94102
f"HTTP {error.response.status_code}: {error_detail}"
95103
) from error
96104

@@ -114,7 +122,7 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> Generator[T, None, None]:
114122
for attempt_number in range(total_attempts):
115123
if time.monotonic() - start > timeout.total_seconds():
116124
raise TimeoutError(
117-
f"Build log streaming timed out after {timeout.total_seconds():.0f}s"
125+
f"Log streaming timed out after {timeout.total_seconds():.0f}s"
118126
)
119127

120128
with attempt(attempt_number):
@@ -144,7 +152,7 @@ def __init__(self) -> None:
144152
},
145153
)
146154

147-
@attempts(BUILD_LOG_MAX_RETRIES, BUILD_LOG_TIMEOUT)
155+
@attempts(STREAM_LOGS_MAX_RETRIES, STREAM_LOGS_TIMEOUT)
148156
def stream_build_logs(
149157
self, deployment_id: str
150158
) -> Generator[BuildLogLine, None, None]:
@@ -192,3 +200,44 @@ def _parse_log_line(self, line: str) -> Optional[BuildLogLine]:
192200
except (ValidationError, json.JSONDecodeError) as e:
193201
logger.debug("Skipping malformed log: %s (error: %s)", line[:100], e)
194202
return None
203+
204+
@attempts(STREAM_LOGS_MAX_RETRIES, STREAM_LOGS_TIMEOUT)
205+
def stream_app_logs(
206+
self,
207+
app_id: str,
208+
tail: int,
209+
since: str,
210+
follow: bool,
211+
) -> Generator[AppLogEntry, None, None]:
212+
timeout = 120 if follow else 30
213+
with self.stream(
214+
"GET",
215+
f"/apps/{app_id}/logs/stream",
216+
params={
217+
"tail": tail,
218+
"since": since,
219+
"follow": follow,
220+
},
221+
timeout=timeout,
222+
) as response:
223+
response.raise_for_status()
224+
for line in response.iter_lines():
225+
if not line or not line.strip(): # pragma: no cover
226+
continue
227+
try:
228+
data = json.loads(line)
229+
except json.JSONDecodeError:
230+
logger.debug("Failed to parse log line: %s", line)
231+
continue
232+
233+
if data.get("type") == "heartbeat":
234+
continue
235+
236+
if data.get("type") == "error":
237+
raise StreamLogError(data.get("message", "Unknown error"))
238+
239+
try:
240+
yield AppLogEntry.model_validate(data)
241+
except ValidationError as e: # pragma: no cover
242+
logger.debug("Failed to parse log entry: %s - %s", data, e)
243+
continue

tests/test_api_client.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@
99

1010
from fastapi_cloud_cli.config import Settings
1111
from fastapi_cloud_cli.utils.api import (
12-
BUILD_LOG_MAX_RETRIES,
12+
STREAM_LOGS_MAX_RETRIES,
1313
APIClient,
14-
BuildLogError,
1514
BuildLogLineMessage,
15+
StreamLogError,
1616
TooManyRetriesError,
1717
)
1818
from tests.utils import build_logs_response
@@ -243,7 +243,7 @@ def test_stream_build_logs_client_error_raises_immediately(
243243
) -> None:
244244
logs_route.mock(return_value=Response(404, text="Not Found"))
245245

246-
with pytest.raises(BuildLogError, match="HTTP 404"):
246+
with pytest.raises(StreamLogError, match="HTTP 404"):
247247
list(client.stream_build_logs(deployment_id))
248248

249249

@@ -255,7 +255,8 @@ def test_stream_build_logs_max_retries_exceeded(
255255

256256
with patch("time.sleep"):
257257
with pytest.raises(
258-
TooManyRetriesError, match=f"Failed after {BUILD_LOG_MAX_RETRIES} attempts"
258+
TooManyRetriesError,
259+
match=f"Failed after {STREAM_LOGS_MAX_RETRIES} attempts",
259260
):
260261
list(client.stream_build_logs(deployment_id))
261262

@@ -343,7 +344,7 @@ def test_stream_build_logs_connection_closed_without_complete_failed_or_timeout(
343344
logs = client.stream_build_logs(deployment_id)
344345

345346
with patch("time.sleep"), pytest.raises(TooManyRetriesError, match="Failed after"):
346-
for _ in range(BUILD_LOG_MAX_RETRIES + 1):
347+
for _ in range(STREAM_LOGS_MAX_RETRIES + 1):
347348
next(logs)
348349

349350

0 commit comments

Comments
 (0)