Skip to content

Commit 54d812c

Browse files
authored
Merge pull request fastapi#30 from fastapilabs/09-13-_improve_error_handling
✨ Improve error handling
2 parents a5e9879 + 2c8602c commit 54d812c

File tree

5 files changed

+109
-124
lines changed

5 files changed

+109
-124
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ dependencies = [
3737
"rignore >= 0.5.1",
3838
"httpx >= 0.27.0",
3939
"pydantic >= 2.8.2",
40-
"rich-toolkit >= 0.6.3",
40+
"rich-toolkit >= 0.7.0",
4141
"pydantic >= 1.6.1",
4242
]
4343

src/fastapi_cli/commands/deploy.py

Lines changed: 43 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,11 @@
66
from enum import Enum
77
from itertools import cycle
88
from pathlib import Path
9-
from typing import Any, Dict, List, Optional, Union
9+
from typing import Any, Dict, List, Union
1010

1111
import rignore
1212
import typer
13-
from httpx import Client, HTTPError, ReadTimeout
13+
from httpx import Client
1414
from pydantic import BaseModel
1515
from rich_toolkit import RichToolkit
1616
from rich_toolkit.menu import Option
@@ -20,7 +20,7 @@
2020
from fastapi_cli.utils.api import APIClient
2121
from fastapi_cli.utils.apps import AppConfig, get_app_config, write_app_config
2222
from fastapi_cli.utils.auth import is_logged_in
23-
from fastapi_cli.utils.cli import get_rich_toolkit
23+
from fastapi_cli.utils.cli import get_rich_toolkit, handle_http_errors
2424

2525
logger = logging.getLogger(__name__)
2626

@@ -66,20 +66,14 @@ class Team(BaseModel):
6666
name: str
6767

6868

69-
def _get_teams() -> Optional[List[Team]]:
69+
def _get_teams() -> List[Team]:
7070
with APIClient() as client:
71-
try:
72-
response = client.get("/teams/")
73-
response.raise_for_status()
74-
except (HTTPError, ReadTimeout) as e:
75-
logger.debug(e)
76-
return None
71+
response = client.get("/teams/")
72+
response.raise_for_status()
7773

7874
data = response.json()["data"]
7975

80-
teams = [Team.model_validate(team) for team in data]
81-
82-
return teams
76+
return [Team.model_validate(team) for team in data]
8377

8478

8579
class CreateAppResponse(BaseModel):
@@ -89,19 +83,14 @@ class CreateAppResponse(BaseModel):
8983
slug: str
9084

9185

92-
def _create_app(team_slug: str, app_name: str) -> Optional[CreateAppResponse]:
86+
def _create_app(team_slug: str, app_name: str) -> CreateAppResponse:
9387
with APIClient() as client:
94-
try:
95-
response = client.post(
96-
"/apps/",
97-
json={"name": app_name, "team_slug": team_slug},
98-
)
99-
except (HTTPError, ReadTimeout) as e:
100-
logger.debug(e)
101-
return None
88+
response = client.post(
89+
"/apps/",
90+
json={"name": app_name, "team_slug": team_slug},
91+
)
10292

103-
if response.status_code != 201:
104-
return None
93+
response.raise_for_status()
10594

10695
return CreateAppResponse.model_validate(response.json())
10796

@@ -121,16 +110,10 @@ class CreateDeploymentResponse(BaseModel):
121110
status: DeploymentStatus
122111

123112

124-
def _create_deployment(app_id: str) -> Optional[CreateDeploymentResponse]:
113+
def _create_deployment(app_id: str) -> CreateDeploymentResponse:
125114
with APIClient() as client:
126-
try:
127-
response = client.post(f"/apps/{app_id}/deployments/")
128-
except (HTTPError, ReadTimeout) as e:
129-
logger.debug(e)
130-
return None
131-
132-
if response.status_code != 201:
133-
return None
115+
response = client.post(f"/apps/{app_id}/deployments/")
116+
response.raise_for_status()
134117

135118
return CreateDeploymentResponse.model_validate(response.json())
136119

@@ -140,32 +123,21 @@ class RequestUploadResponse(BaseModel):
140123
fields: Dict[str, str]
141124

142125

143-
def _upload_deployment(deployment_id: str, archive_path: Path) -> bool:
126+
def _upload_deployment(deployment_id: str, archive_path: Path) -> None:
144127
with APIClient() as client:
145-
try:
146-
response = client.post(f"/deployments/{deployment_id}/upload")
147-
148-
response.raise_for_status()
149-
except (HTTPError, ReadTimeout) as e:
150-
logger.debug(e)
151-
return False
128+
response = client.post(f"/deployments/{deployment_id}/upload")
129+
response.raise_for_status()
152130

153131
upload_data = RequestUploadResponse.model_validate(response.json())
154132

155133
with Client() as client:
156-
try:
157-
response = client.post(
158-
upload_data.url,
159-
data=upload_data.fields,
160-
files={"file": archive_path.open("rb")},
161-
)
162-
163-
response.raise_for_status()
164-
except (HTTPError, ReadTimeout) as e:
165-
logger.debug(e)
166-
return False
134+
response = client.post(
135+
upload_data.url,
136+
data=upload_data.fields,
137+
files={"file": archive_path.open("rb")},
138+
)
167139

168-
return True
140+
response.raise_for_status()
169141

170142

171143
class DeploymentResponse(BaseModel):
@@ -176,19 +148,13 @@ class DeploymentResponse(BaseModel):
176148
url: str
177149

178150

179-
def _get_deployment(app_id: str, deployment_id: str) -> Optional[DeploymentResponse]:
151+
def _get_deployment(app_id: str, deployment_id: str) -> DeploymentResponse:
180152
with APIClient() as client:
181-
try:
182-
response = client.get(f"/apps/{app_id}/deployments/{deployment_id}")
183-
except (HTTPError, ReadTimeout) as e:
184-
logger.debug(e)
185-
return None
153+
response = client.get(f"/apps/{app_id}/deployments/{deployment_id}")
154+
response.raise_for_status()
186155

187156
data = response.json()
188157

189-
if response.status_code != 200:
190-
return None
191-
192158
return DeploymentResponse.model_validate(data)
193159

194160

@@ -220,12 +186,10 @@ def _configure_app(toolkit: RichToolkit, path_to_deploy: Path) -> AppConfig:
220186
toolkit.print_line()
221187

222188
with toolkit.progress("Fetching teams...") as progress:
223-
teams = _get_teams()
224-
225-
if teams is None:
226-
progress.set_error("Error fetching teams. Please try again later.")
227-
228-
raise typer.Exit(1)
189+
with handle_http_errors(
190+
progress, message="Error fetching teams. Please try again later."
191+
):
192+
teams = _get_teams()
229193

230194
toolkit.print_line()
231195

@@ -246,12 +210,8 @@ def _configure_app(toolkit: RichToolkit, path_to_deploy: Path) -> AppConfig:
246210
toolkit.print_line()
247211

248212
with toolkit.progress(title="Creating app...") as progress:
249-
app_data = _create_app(team_slug, app_name)
250-
251-
if app_data is None:
252-
progress.set_error("Error creating app. Please try again later.")
253-
254-
raise typer.Exit(1)
213+
with handle_http_errors(progress):
214+
app_data = _create_app(team_slug, app_name)
255215

256216
progress.log(f"App created successfully! App slug: {app_data.slug}")
257217

@@ -278,14 +238,8 @@ def _wait_for_deployment(
278238

279239
with toolkit.progress("Deploying...") as progress:
280240
while True:
281-
deployment = _get_deployment(app_id, deployment_id)
282-
283-
if deployment is None:
284-
progress.set_error(
285-
"[error]Error fetching deployment status.[/]\n\nPlease try again later."
286-
)
287-
288-
raise typer.Exit(1) from None
241+
with handle_http_errors(progress):
242+
deployment = _get_deployment(app_id, deployment_id)
289243

290244
if deployment.status == DeploymentStatus.success:
291245
progress.log(
@@ -354,25 +308,16 @@ def deploy(
354308
archive_path = archive(path or Path.cwd()) # noqa: F841
355309

356310
with toolkit.progress(title="Creating deployment") as progress:
357-
deployment = _create_deployment(app_config.id)
358-
359-
if deployment is None:
360-
progress.set_error("Error creating deployment. Please try again later.")
361-
362-
raise typer.Exit(1)
363-
364-
progress.log(
365-
f"Deployment created successfully! Deployment slug: {deployment.slug}"
366-
)
367-
368-
progress.log("Uploading deployment...")
311+
with handle_http_errors(progress):
312+
deployment = _create_deployment(app_config.id)
369313

370-
if not _upload_deployment(deployment.id, archive_path):
371-
progress.set_error(
372-
"[error]Something went while upload the archive[/]\n\nPlease try again later."
314+
progress.log(
315+
f"Deployment created successfully! Deployment slug: {deployment.slug}"
373316
)
374317

375-
raise typer.Exit(1) from None
318+
progress.log("Uploading deployment...")
319+
320+
_upload_deployment(deployment.id, archive_path)
376321

377322
progress.log("Deployment uploaded successfully!")
378323

src/fastapi_cli/commands/whoami.py

Lines changed: 6 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import logging
22
from typing import Any
33

4-
import httpx
5-
import typer
64
from rich import print
5+
from rich_toolkit.progress import Progress
76

87
from fastapi_cli.utils.api import APIClient
98
from fastapi_cli.utils.auth import is_logged_in
9+
from fastapi_cli.utils.cli import handle_http_errors
1010

1111
logger = logging.getLogger(__name__)
1212

@@ -17,29 +17,10 @@ def whoami() -> Any:
1717
return
1818

1919
with APIClient() as client:
20-
try:
21-
response = client.get("/users/me")
22-
except httpx.ReadTimeout:
23-
print(
24-
"[red]Error[/]: The request to the FastAPI Cloud server timed out. Please try again later."
25-
)
26-
raise typer.Exit(code=1) from None
27-
28-
if response.status_code in (401, 403):
29-
print(
30-
"[red]Error[/]: The specified token is not valid. Use [blue]`fastapi login`[/] to generate a new token."
31-
)
32-
raise typer.Exit(code=1) from None
33-
34-
try:
35-
response.raise_for_status()
36-
except httpx.HTTPError as e:
37-
logger.debug("Error: %s", e)
38-
39-
print(
40-
"[red]Error[/]: Something went wrong while contacting the FastAPI Cloud server. Please try again later."
41-
)
42-
raise typer.Exit(code=1) from None
20+
with Progress(title="⚡Fetching profile", transient=True) as progress:
21+
with handle_http_errors(progress, message=""):
22+
response = client.get("/users/me")
23+
response.raise_for_status()
4324

4425
data = response.json()
4526

src/fastapi_cli/utils/cli.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
1+
import contextlib
2+
import logging
3+
from typing import Generator, Optional
4+
5+
import typer
6+
from httpx import HTTPError, HTTPStatusError, ReadTimeout
17
from rich_toolkit import RichToolkit, RichToolkitTheme
8+
from rich_toolkit.progress import Progress
29
from rich_toolkit.styles import TaggedStyle
310

11+
logger = logging.getLogger(__name__)
12+
413

514
def get_rich_toolkit() -> RichToolkit:
615
theme = RichToolkitTheme(
@@ -18,3 +27,35 @@ def get_rich_toolkit() -> RichToolkit:
1827
)
1928

2029
return RichToolkit(theme=theme)
30+
31+
32+
@contextlib.contextmanager
33+
def handle_http_errors(
34+
progress: Progress,
35+
message: Optional[str] = None,
36+
) -> Generator[None, None, None]:
37+
try:
38+
yield
39+
except ReadTimeout as e:
40+
logger.debug(e)
41+
42+
progress.set_error(
43+
"The request to the FastAPI Cloud server timed out. Please try again later."
44+
)
45+
46+
raise typer.Exit(1) from None
47+
except HTTPError as e:
48+
logger.debug(e)
49+
50+
if isinstance(e, HTTPStatusError) and e.response.status_code in (401, 403):
51+
message = "The specified token is not valid. Use `fastapi login` to generate a new token."
52+
53+
else:
54+
message = (
55+
message
56+
or f"Something went wrong while contacting the FastAPI Cloud server. Please try again later. \n\n{e}"
57+
)
58+
59+
progress.set_error(message)
60+
61+
raise typer.Exit(1) from None

tests/test_cli_deploy.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,24 @@ def test_shows_error_when_trying_to_get_teams(
7272
assert "Error fetching teams. Please try again later" in result.output
7373

7474

75+
@pytest.mark.respx(base_url=settings.base_api_url)
76+
def test_handles_invalid_auth(
77+
logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter
78+
) -> None:
79+
steps = [ENTER]
80+
81+
respx_mock.get("/teams/").mock(return_value=Response(401))
82+
83+
with changing_dir(tmp_path), patch("click.getchar") as mock_getchar:
84+
mock_getchar.side_effect = steps
85+
86+
result = runner.invoke(app, ["deploy"])
87+
88+
assert result.exit_code == 1
89+
90+
assert "The specified token is not valid" in result.output
91+
92+
7593
@pytest.mark.respx(base_url=settings.base_api_url)
7694
def test_shows_teams(
7795
logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter

0 commit comments

Comments
 (0)