Skip to content

Commit ce8269c

Browse files
authored
✨ Wait for verification after building (#160)
Shortcake-Parent: main
1 parent 4dc8622 commit ce8269c

File tree

8 files changed

+460
-78
lines changed

8 files changed

+460
-78
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ dependencies = [
3232
"uvicorn[standard] >= 0.17.6",
3333
"rignore >= 0.5.1",
3434
"httpx >= 0.27.0",
35-
"rich-toolkit >= 0.19.6",
35+
"rich-toolkit >= 0.19.7",
3636
"pydantic[email] >= 2.7.4; python_version < '3.13'",
3737
"pydantic[email] >= 2.8.0; python_version == '3.13'",
3838
"pydantic[email] >= 2.12.0; python_version >= '3.14'",

src/fastapi_cloud_cli/commands/deploy.py

Lines changed: 51 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
import subprocess
55
import tempfile
66
import time
7-
from enum import Enum
87
from itertools import cycle
98
from pathlib import Path, PurePosixPath
109
from textwrap import dedent
@@ -20,7 +19,13 @@
2019
from rich_toolkit.menu import Option
2120

2221
from fastapi_cloud_cli.commands.login import login
23-
from fastapi_cloud_cli.utils.api import APIClient, StreamLogError, TooManyRetriesError
22+
from fastapi_cloud_cli.utils.api import (
23+
SUCCESSFUL_STATUSES,
24+
APIClient,
25+
DeploymentStatus,
26+
StreamLogError,
27+
TooManyRetriesError,
28+
)
2429
from fastapi_cloud_cli.utils.apps import AppConfig, get_app_config, write_app_config
2530
from fastapi_cloud_cli.utils.auth import Identity
2631
from fastapi_cloud_cli.utils.cli import get_rich_toolkit, handle_http_errors
@@ -174,42 +179,6 @@ def _create_app(team_id: str, app_name: str, directory: str | None) -> AppRespon
174179
return AppResponse.model_validate(response.json())
175180

176181

177-
class DeploymentStatus(str, Enum):
178-
waiting_upload = "waiting_upload"
179-
ready_for_build = "ready_for_build"
180-
building = "building"
181-
extracting = "extracting"
182-
extracting_failed = "extracting_failed"
183-
building_image = "building_image"
184-
building_image_failed = "building_image_failed"
185-
deploying = "deploying"
186-
deploying_failed = "deploying_failed"
187-
verifying = "verifying"
188-
verifying_failed = "verifying_failed"
189-
verifying_skipped = "verifying_skipped"
190-
success = "success"
191-
failed = "failed"
192-
193-
@classmethod
194-
def to_human_readable(cls, status: "DeploymentStatus") -> str:
195-
return {
196-
cls.waiting_upload: "Waiting for upload",
197-
cls.ready_for_build: "Ready for build",
198-
cls.building: "Building",
199-
cls.extracting: "Extracting",
200-
cls.extracting_failed: "Extracting failed",
201-
cls.building_image: "Building image",
202-
cls.building_image_failed: "Build failed",
203-
cls.deploying: "Deploying",
204-
cls.deploying_failed: "Deploying failed",
205-
cls.verifying: "Verifying",
206-
cls.verifying_failed: "Verifying failed",
207-
cls.verifying_skipped: "Verification skipped",
208-
cls.success: "Success",
209-
cls.failed: "Failed",
210-
}[status]
211-
212-
213182
class CreateDeploymentResponse(BaseModel):
214183
id: str
215184
app_id: str
@@ -440,6 +409,42 @@ def _configure_app(toolkit: RichToolkit, path_to_deploy: Path) -> AppConfig:
440409
return app_config
441410

442411

412+
def _verify_deployment(
413+
toolkit: RichToolkit,
414+
client: APIClient,
415+
app_id: str,
416+
deployment: CreateDeploymentResponse,
417+
) -> None:
418+
with toolkit.progress(
419+
title="Verifying deployment...",
420+
inline_logs=True,
421+
done_emoji="✅",
422+
) as progress:
423+
try:
424+
final_status = client.poll_deployment_status(app_id, deployment.id)
425+
except (TimeoutError, TooManyRetriesError, StreamLogError):
426+
progress.metadata["done_emoji"] = "⚠️"
427+
progress.current_message = (
428+
f"Could not confirm deployment status. "
429+
f"Check the dashboard: [link={deployment.dashboard_url}]{deployment.dashboard_url}[/link]"
430+
)
431+
return
432+
433+
if final_status in SUCCESSFUL_STATUSES:
434+
progress.current_message = f"Ready the chicken! 🐔 Your app is ready at [link={deployment.url}]{deployment.url}[/link]"
435+
else:
436+
progress.metadata["done_emoji"] = "❌"
437+
progress.current_message = "Deployment failed"
438+
439+
human_status = DeploymentStatus.to_human_readable(final_status)
440+
441+
progress.log(
442+
f"😔 Oh no! Deployment failed: {human_status}. "
443+
f"Check out the logs at [link={deployment.dashboard_url}]{deployment.dashboard_url}[/link]"
444+
)
445+
raise typer.Exit(1)
446+
447+
443448
def _wait_for_deployment(
444449
toolkit: RichToolkit, app_id: str, deployment: CreateDeploymentResponse
445450
) -> None:
@@ -451,11 +456,6 @@ def _wait_for_deployment(
451456
)
452457
toolkit.print_line()
453458

454-
toolkit.print(
455-
f"You can also check the status at [link={deployment.dashboard_url}]{deployment.dashboard_url}[/link]",
456-
)
457-
toolkit.print_line()
458-
459459
time_elapsed = 0.0
460460

461461
started_at = time.monotonic()
@@ -471,6 +471,8 @@ def _wait_for_deployment(
471471
) as progress,
472472
APIClient() as client,
473473
):
474+
build_complete = False
475+
474476
try:
475477
for log in client.stream_build_logs(deployment.id):
476478
time_elapsed = time.monotonic() - started_at
@@ -479,18 +481,8 @@ def _wait_for_deployment(
479481
progress.log(Text.from_ansi(log.message.rstrip()))
480482

481483
if log.type == "complete":
484+
build_complete = True
482485
progress.title = "Build complete!"
483-
progress.log("")
484-
progress.log(
485-
f"You can also check the app logs at [link={deployment.dashboard_url}]{deployment.dashboard_url}[/link]"
486-
)
487-
488-
progress.log("")
489-
490-
progress.log(
491-
f"🐔 Ready the chicken! Your app is ready at [link={deployment.url}]{deployment.url}[/link]"
492-
)
493-
494486
break
495487

496488
if log.type == "failed":
@@ -519,6 +511,11 @@ def _wait_for_deployment(
519511

520512
raise typer.Exit(1) from None
521513

514+
if build_complete:
515+
toolkit.print_line()
516+
517+
_verify_deployment(toolkit, client, app_id, deployment)
518+
522519

523520
class SignupToWaitingList(BaseModel):
524521
email: EmailStr

src/fastapi_cloud_cli/utils/api.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from collections.abc import Callable, Generator
55
from contextlib import contextmanager
66
from datetime import timedelta
7+
from enum import Enum
78
from functools import wraps
89
from typing import (
910
Annotated,
@@ -138,6 +139,57 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> Generator[T, None, None]:
138139
return decorator
139140

140141

142+
class DeploymentStatus(str, Enum):
143+
waiting_upload = "waiting_upload"
144+
ready_for_build = "ready_for_build"
145+
building = "building"
146+
extracting = "extracting"
147+
extracting_failed = "extracting_failed"
148+
building_image = "building_image"
149+
building_image_failed = "building_image_failed"
150+
deploying = "deploying"
151+
deploying_failed = "deploying_failed"
152+
verifying = "verifying"
153+
verifying_failed = "verifying_failed"
154+
verifying_skipped = "verifying_skipped"
155+
success = "success"
156+
failed = "failed"
157+
158+
@classmethod
159+
def to_human_readable(cls, status: "DeploymentStatus") -> str:
160+
return {
161+
cls.waiting_upload: "Waiting for upload",
162+
cls.ready_for_build: "Ready for build",
163+
cls.building: "Building",
164+
cls.extracting: "Extracting",
165+
cls.extracting_failed: "Extracting failed",
166+
cls.building_image: "Building image",
167+
cls.building_image_failed: "Build failed",
168+
cls.deploying: "Deploying",
169+
cls.deploying_failed: "Deploying failed",
170+
cls.verifying: "Verifying",
171+
cls.verifying_failed: "Verifying failed",
172+
cls.verifying_skipped: "Verification skipped",
173+
cls.success: "Success",
174+
cls.failed: "Failed",
175+
}[status]
176+
177+
178+
SUCCESSFUL_STATUSES = {DeploymentStatus.success, DeploymentStatus.verifying_skipped}
179+
FAILED_STATUSES = {
180+
DeploymentStatus.failed,
181+
DeploymentStatus.verifying_failed,
182+
DeploymentStatus.deploying_failed,
183+
DeploymentStatus.building_image_failed,
184+
DeploymentStatus.extracting_failed,
185+
}
186+
TERMINAL_STATUSES = SUCCESSFUL_STATUSES | FAILED_STATUSES
187+
188+
POLL_INTERVAL = 2.0
189+
POLL_TIMEOUT = timedelta(seconds=120)
190+
POLL_MAX_RETRIES = 5
191+
192+
141193
class APIClient(httpx.Client):
142194
def __init__(self) -> None:
143195
settings = Settings.get()
@@ -241,3 +293,33 @@ def stream_app_logs(
241293
except ValidationError as e: # pragma: no cover
242294
logger.debug("Failed to parse log entry: %s - %s", data, e)
243295
continue
296+
297+
def poll_deployment_status(
298+
self,
299+
app_id: str,
300+
deployment_id: str,
301+
) -> DeploymentStatus:
302+
start = time.monotonic()
303+
error_count = 0
304+
305+
while True:
306+
if time.monotonic() - start > POLL_TIMEOUT.total_seconds():
307+
raise TimeoutError("Deployment verification timed out")
308+
309+
with attempt(error_count):
310+
response = self.get(f"/apps/{app_id}/deployments/{deployment_id}")
311+
response.raise_for_status()
312+
status = DeploymentStatus(response.json()["status"])
313+
error_count = 0
314+
315+
if status in TERMINAL_STATUSES:
316+
return status
317+
318+
time.sleep(POLL_INTERVAL)
319+
continue
320+
321+
error_count += 1
322+
if error_count >= POLL_MAX_RETRIES:
323+
raise TooManyRetriesError(
324+
f"Failed after {POLL_MAX_RETRIES} attempts polling deployment status"
325+
)

src/fastapi_cloud_cli/utils/cli.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import contextlib
22
import logging
33
from collections.abc import Generator
4-
from typing import Any
4+
from typing import Any, Literal
55

66
import typer
77
from httpx import HTTPError, HTTPStatusError, ReadTimeout
@@ -24,9 +24,12 @@ def _get_tag_segments(
2424
metadata: dict[str, Any],
2525
is_animated: bool = False,
2626
done: bool = False,
27+
animation_status: Literal["started", "stopped", "error"] | None = None,
2728
) -> tuple[list[Segment], int]:
2829
if not is_animated:
29-
return super()._get_tag_segments(metadata, is_animated, done)
30+
return super()._get_tag_segments(
31+
metadata, is_animated, done, animation_status=animation_status
32+
)
3033

3134
emojis = [
3235
"🥚",
@@ -42,6 +45,9 @@ def _get_tag_segments(
4245
if done:
4346
tag = metadata.get("done_emoji", emojis[-1])
4447

48+
if animation_status == "error":
49+
tag = "🟡"
50+
4551
left_padding = self.tag_width - 1
4652
left_padding = max(0, left_padding)
4753

tests/test_api_client.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
STREAM_LOGS_MAX_RETRIES,
1212
APIClient,
1313
BuildLogLineMessage,
14+
DeploymentStatus,
1415
StreamLogError,
1516
TooManyRetriesError,
1617
)
@@ -351,3 +352,55 @@ def responses(request: httpx.Request, route: respx.Route) -> Response:
351352

352353
with patch("time.sleep"), pytest.raises(TimeoutError, match="timed out"):
353354
list(client.stream_build_logs(deployment_id))
355+
356+
357+
@pytest.fixture
358+
def app_id() -> str:
359+
return "test-app-456"
360+
361+
362+
@pytest.fixture
363+
def poll_route(
364+
respx_mock: respx.MockRouter, app_id: str, deployment_id: str
365+
) -> respx.Route:
366+
return respx_mock.get(f"/apps/{app_id}/deployments/{deployment_id}")
367+
368+
369+
def test_poll_deployment_status_recovers_from_transient_errors(
370+
poll_route: respx.Route, client: APIClient, app_id: str, deployment_id: str
371+
) -> None:
372+
call_count = 0
373+
374+
def handler(request: httpx.Request, route: respx.Route) -> Response:
375+
nonlocal call_count
376+
call_count += 1
377+
if call_count <= 2:
378+
return Response(500)
379+
return Response(200, json={"status": "success"})
380+
381+
poll_route.mock(side_effect=handler)
382+
383+
with patch("time.sleep"):
384+
status = client.poll_deployment_status(app_id, deployment_id)
385+
386+
assert status == DeploymentStatus.success
387+
assert call_count == 3
388+
389+
390+
def test_poll_deployment_status_raises_after_max_consecutive_errors(
391+
poll_route: respx.Route, client: APIClient, app_id: str, deployment_id: str
392+
) -> None:
393+
poll_route.mock(return_value=Response(500))
394+
395+
with patch("time.sleep"), pytest.raises(TooManyRetriesError):
396+
client.poll_deployment_status(app_id, deployment_id)
397+
398+
399+
def test_poll_deployment_status_timeout(
400+
client: APIClient, app_id: str, deployment_id: str
401+
) -> None:
402+
with (
403+
patch("fastapi_cloud_cli.utils.api.POLL_TIMEOUT", timedelta(seconds=-1)),
404+
pytest.raises(TimeoutError, match="timed out"),
405+
):
406+
client.poll_deployment_status(app_id, deployment_id)

0 commit comments

Comments
 (0)