Skip to content

Commit 1412b7d

Browse files
committed
♻️ Improve how we handle invalid tokens
Shortcake-Parent: main
1 parent 9df4e57 commit 1412b7d

File tree

12 files changed

+129
-95
lines changed

12 files changed

+129
-95
lines changed

src/fastapi_cloud_cli/commands/deploy.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,7 @@ def _configure_app(toolkit: RichToolkit, path_to_deploy: Path) -> AppConfig:
278278

279279
with toolkit.progress("Fetching teams...") as progress:
280280
with handle_http_errors(
281-
progress, message="Error fetching teams. Please try again later."
281+
progress, default_message="Error fetching teams. Please try again later."
282282
):
283283
teams = _get_teams()
284284

@@ -303,7 +303,7 @@ def _configure_app(toolkit: RichToolkit, path_to_deploy: Path) -> AppConfig:
303303
if not create_new_app:
304304
with toolkit.progress("Fetching apps...") as progress:
305305
with handle_http_errors(
306-
progress, message="Error fetching apps. Please try again later."
306+
progress, default_message="Error fetching apps. Please try again later."
307307
):
308308
apps = _get_apps(team.id)
309309

@@ -586,10 +586,16 @@ def deploy(
586586
toolkit.print_title("Welcome to FastAPI Cloud!", tag="FastAPI")
587587
toolkit.print_line()
588588

589-
toolkit.print(
590-
"You need to be logged in to deploy to FastAPI Cloud.",
591-
tag="info",
592-
)
589+
if identity.token and identity.is_expired():
590+
toolkit.print(
591+
"Your session has expired. Please log in again.",
592+
tag="info",
593+
)
594+
else:
595+
toolkit.print(
596+
"You need to be logged in to deploy to FastAPI Cloud.",
597+
tag="info",
598+
)
593599
toolkit.print_line()
594600

595601
choice = toolkit.ask(
@@ -601,8 +607,6 @@ def deploy(
601607
],
602608
)
603609

604-
toolkit.print_line()
605-
606610
if choice == "login":
607611
login()
608612
else:

src/fastapi_cloud_cli/commands/link.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@ def link() -> Any:
4949

5050
with toolkit.progress("Fetching teams...") as progress:
5151
with handle_http_errors(
52-
progress, message="Error fetching teams. Please try again later."
52+
progress,
53+
default_message="Error fetching teams. Please try again later.",
5354
):
5455
with APIClient() as client:
5556
response = client.get("/teams/")
@@ -77,7 +78,7 @@ def link() -> Any:
7778

7879
with toolkit.progress("Fetching apps...") as progress:
7980
with handle_http_errors(
80-
progress, message="Error fetching apps. Please try again later."
81+
progress, default_message="Error fetching apps. Please try again later."
8182
):
8283
with APIClient() as client:
8384
response = client.get("/apps/", params={"team_id": team["id"]})

src/fastapi_cloud_cli/commands/login.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -78,17 +78,13 @@ def login() -> Any:
7878
"""
7979
identity = Identity()
8080

81-
if identity.is_expired():
82-
with get_rich_toolkit(minimal=True) as toolkit:
83-
toolkit.print("Your session has expired. Logging in again...")
84-
toolkit.print_line()
85-
8681
if identity.is_logged_in():
8782
with get_rich_toolkit(minimal=True) as toolkit:
8883
toolkit.print("You are already logged in.")
8984
toolkit.print(
9085
"Run [bold]fastapi cloud logout[/bold] first if you want to switch accounts."
9186
)
87+
9288
return
9389

9490
with get_rich_toolkit() as toolkit, APIClient() as client:

src/fastapi_cloud_cli/commands/logs.py

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from typing import Annotated, Optional
66

77
import typer
8+
from httpx import HTTPError
89
from rich.markup import escape
910
from rich_toolkit import RichToolkit
1011

@@ -16,7 +17,7 @@
1617
)
1718
from fastapi_cloud_cli.utils.apps import AppConfig, get_app_config
1819
from fastapi_cloud_cli.utils.auth import Identity
19-
from fastapi_cloud_cli.utils.cli import get_rich_toolkit
20+
from fastapi_cloud_cli.utils.cli import get_rich_toolkit, handle_http_error
2021

2122
logger = logging.getLogger(__name__)
2223

@@ -85,21 +86,20 @@ def _process_log_stream(
8586
return
8687
except KeyboardInterrupt: # pragma: no cover
8788
toolkit.print_line()
89+
8890
return
8991
except StreamLogError as e:
90-
error_msg = str(e)
91-
if "HTTP 401" in error_msg or "HTTP 403" in error_msg:
92-
toolkit.print(
93-
"The specified token is not valid. Use [blue]`fastapi login`[/] to generate a new token.",
94-
)
95-
elif "HTTP 404" in error_msg:
96-
toolkit.print(
97-
"App not found. Make sure to use the correct account.",
98-
)
92+
if e.status_code == 404:
93+
message = "App not found. Make sure to use the correct account."
94+
95+
elif isinstance(e.__cause__, HTTPError):
96+
message = handle_http_error(e.__cause__)
97+
9998
else:
100-
toolkit.print(
101-
f"[red]Error:[/] {escape(error_msg)}",
102-
)
99+
message = f"[red]Error:[/] {escape(str(e))}"
100+
101+
toolkit.print(message)
102+
103103
raise typer.Exit(1) from None
104104
except (TooManyRetriesError, TimeoutError):
105105
toolkit.print(

src/fastapi_cloud_cli/commands/whoami.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ def whoami() -> Any:
2424

2525
with APIClient() as client:
2626
with Progress(title="⚡ Fetching profile", transient=True) as progress:
27-
with handle_http_errors(progress, message=""):
27+
with handle_http_errors(progress, default_message=""):
2828
response = client.get("/users/me")
2929
response.raise_for_status()
3030

src/fastapi_cloud_cli/utils/api.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@
3232
class StreamLogError(Exception):
3333
"""Raised when there's an error streaming logs (build or app logs)."""
3434

35-
pass
35+
def __init__(self, message: str, *, status_code: Optional[int] = None) -> None:
36+
super().__init__(message)
37+
self.status_code = status_code
3638

3739

3840
class TooManyRetriesError(Exception):
@@ -100,7 +102,8 @@ def _backoff() -> None:
100102
except Exception:
101103
error_detail = "(response body unavailable)"
102104
raise StreamLogError(
103-
f"HTTP {error.response.status_code}: {error_detail}"
105+
f"HTTP {error.response.status_code}: {error_detail}",
106+
status_code=error.response.status_code,
104107
) from error
105108

106109

src/fastapi_cloud_cli/utils/cli.py

Lines changed: 43 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from rich_toolkit.progress import Progress
1111
from rich_toolkit.styles import MinimalStyle, TaggedStyle
1212

13-
from .auth import Identity
13+
from .auth import Identity, delete_auth_config
1414

1515
logger = logging.getLogger(__name__)
1616

@@ -68,10 +68,50 @@ def get_rich_toolkit(minimal: bool = False) -> RichToolkit:
6868
return RichToolkit(theme=theme)
6969

7070

71+
def handle_unauthorized() -> str:
72+
message = "The specified token is not valid. "
73+
74+
identity = Identity()
75+
76+
if identity.auth_mode == "user":
77+
delete_auth_config()
78+
79+
message += "Use `fastapi login` to generate a new token."
80+
else:
81+
message += "Make sure to use a valid token."
82+
83+
return message
84+
85+
86+
def handle_http_error(error: HTTPError, default_message: Optional[str] = None) -> str:
87+
message: Optional[str] = None
88+
89+
if isinstance(error, HTTPStatusError):
90+
status_code = error.response.status_code
91+
92+
# Handle validation errors from Pydantic models, this should make it easier to debug :)
93+
if status_code == 422:
94+
logger.debug(error.response.json()) # pragma: no cover
95+
96+
elif status_code == 401:
97+
message = handle_unauthorized()
98+
99+
elif status_code == 403:
100+
message = "You don't have permissions for this resource"
101+
102+
if not message:
103+
message = (
104+
default_message
105+
or f"Something went wrong while contacting the FastAPI Cloud server. Please try again later. \n\n{error}"
106+
)
107+
108+
return message
109+
110+
71111
@contextlib.contextmanager
72112
def handle_http_errors(
73113
progress: Progress,
74-
message: Optional[str] = None,
114+
default_message: Optional[str] = None,
75115
) -> Generator[None, None, None]:
76116
try:
77117
yield
@@ -86,25 +126,7 @@ def handle_http_errors(
86126
except HTTPError as e:
87127
logger.debug(e)
88128

89-
# Handle validation errors from Pydantic models, this should make it easier to debug :)
90-
if isinstance(e, HTTPStatusError) and e.response.status_code == 422:
91-
logger.debug(e.response.json()) # pragma: no cover
92-
93-
if isinstance(e, HTTPStatusError) and e.response.status_code in (401, 403):
94-
message = "The specified token is not valid. "
95-
96-
identity = Identity()
97-
98-
if identity.auth_mode == "user":
99-
message += "Use `fastapi login` to generate a new token."
100-
else:
101-
message += "Make sure to use a valid token."
102-
103-
else:
104-
message = (
105-
message
106-
or f"Something went wrong while contacting the FastAPI Cloud server. Please try again later. \n\n{e}"
107-
)
129+
message = handle_http_error(e, default_message)
108130

109131
progress.set_error(message)
110132

tests/test_auth.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,11 @@ def test_is_jwt_expired_edge_case_one_second_before() -> None:
7878
assert not _is_jwt_expired(token)
7979

8080

81+
def test_is_expired_with_no_token(temp_auth_config: Path) -> None:
82+
assert not temp_auth_config.exists()
83+
assert Identity().is_expired()
84+
85+
8186
def test_is_logged_in_with_no_token(temp_auth_config: Path) -> None:
8287
assert not temp_auth_config.exists()
8388
assert not Identity().is_logged_in()

tests/test_cli_deploy.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from fastapi_cloud_cli.config import Settings
1818
from fastapi_cloud_cli.utils.api import StreamLogError, TooManyRetriesError
1919
from tests.conftest import ConfiguredApp
20-
from tests.utils import Keys, build_logs_response, changing_dir
20+
from tests.utils import Keys, build_logs_response, changing_dir, create_jwt_token
2121

2222
runner = CliRunner()
2323

@@ -216,6 +216,24 @@ def test_shows_waitlist_form_when_not_logged_in_longer_flow(
216216
assert "Let's go! Thanks for your interest in FastAPI Cloud! 🚀" in result.output
217217

218218

219+
def test_shows_login_prompt_when_token_is_expired(
220+
temp_auth_config: Path, tmp_path: Path
221+
) -> None:
222+
expired_token = create_jwt_token({"sub": "test_user", "exp": 0})
223+
temp_auth_config.write_text(f'{{"access_token": "{expired_token}"}}')
224+
225+
with (
226+
changing_dir(tmp_path),
227+
patch("rich_toolkit.container.getchar") as mock_getchar,
228+
):
229+
mock_getchar.side_effect = [Keys.CTRL_C]
230+
result = runner.invoke(app, ["deploy"])
231+
232+
assert "Welcome to FastAPI Cloud!" in result.output
233+
assert "Your session has expired. Please log in again." in result.output
234+
assert "What would you like to do?" in result.output
235+
236+
219237
@pytest.mark.respx
220238
def test_shows_error_when_trying_to_get_teams(
221239
logged_in_cli: None, tmp_path: Path, respx_mock: respx.MockRouter

tests/test_cli_login.py

Lines changed: 0 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import time
21
from pathlib import Path
32
from unittest.mock import patch
43

@@ -10,7 +9,6 @@
109

1110
from fastapi_cloud_cli.cli import app
1211
from fastapi_cloud_cli.config import Settings
13-
from tests.utils import create_jwt_token
1412

1513
runner = CliRunner()
1614

@@ -187,43 +185,3 @@ def test_notify_already_logged_in_user(
187185
"Run fastapi cloud logout first if you want to switch accounts."
188186
in result.output
189187
)
190-
191-
192-
@pytest.mark.respx
193-
def test_notify_expired_token_user(
194-
respx_mock: respx.MockRouter, temp_auth_config: Path, settings: Settings
195-
) -> None:
196-
past_exp = int(time.time()) - 3600
197-
expired_token = create_jwt_token({"sub": "test_user_12345", "exp": past_exp})
198-
199-
temp_auth_config.write_text(f'{{"access_token": "{expired_token}"}}')
200-
201-
with patch("fastapi_cloud_cli.commands.login.typer.launch") as mock_open:
202-
respx_mock.post(
203-
"/login/device/authorization", data={"client_id": settings.client_id}
204-
).mock(
205-
return_value=Response(
206-
200,
207-
json={
208-
"verification_uri_complete": "http://test.com",
209-
"verification_uri": "http://test.com",
210-
"user_code": "1234",
211-
"device_code": "5678",
212-
},
213-
)
214-
)
215-
respx_mock.post(
216-
"/login/device/token",
217-
data={
218-
"device_code": "5678",
219-
"client_id": settings.client_id,
220-
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
221-
},
222-
).mock(return_value=Response(200, json={"access_token": "new_token_1234"}))
223-
224-
result = runner.invoke(app, ["login"])
225-
226-
assert result.exit_code == 0
227-
assert "Your session has expired. Logging in again..." in result.output
228-
assert "Now you are logged in!" in result.output
229-
assert mock_open.called

0 commit comments

Comments
 (0)