Skip to content

Commit 56ecc6b

Browse files
punitaraniclaude
andauthored
Add typed error handling and clean CLI error reporting (#159)
* Surface network errors with clean messages + tmp log file Network failures (curl timeout / DNS / HTTP error) used to bubble up as a multi-frame `curl_cffi` + `tenacity` traceback in the CLI. Wrap them in typed `SearchClientError` subclasses inside the HTTP client and add a shared CLI error reporter that prints a one-line message and writes the full traceback to `$TMPDIR/fli-logs/fli-error-<ts>.log` for debugging. JSON mode includes the log path and a typed `error.type`. * Address review: dedupe log filenames, thread command into JSON path, pass through typed errors - `_wrap_request_error`: pass through existing `SearchClientError` instances instead of downgrading them to the generic fallback. - Log filename now uses microsecond precision so rapid-fire errors (tests, parallel multi-city legs) don't collide and silently overwrite. - `json_error_payload` accepts an optional `command` and forwards it to `_write_log`, matching the text-mode path so JSON logs record which CLI command triggered the error. * Use ~/.fli/logs/ for error log files instead of system tmpdir macOS tempfile.gettempdir() returns a long ephemeral path like /var/folders/yn/.../T/ which is ugly to display and hard to find. ~/.fli/logs/ is short, predictable, and consistent across all OSes. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5ca678c commit 56ecc6b

8 files changed

Lines changed: 395 additions & 5 deletions

File tree

fli/cli/commands/dates.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import typer
77

88
from fli.cli.enums import DayOfWeek, OutputFormat
9+
from fli.cli.errors import json_error_payload, report_cli_error
910
from fli.cli.utils import (
1011
build_json_error_response,
1112
build_json_success_response,
@@ -32,7 +33,7 @@
3233
TimeRestrictions,
3334
TripType,
3435
)
35-
from fli.search import SearchDates
36+
from fli.search import SearchClientError, SearchDates
3637

3738

3839
def _build_selected_days(
@@ -441,6 +442,18 @@ def dates(
441442
raise typer.Exit(1) from e
442443
typer.echo(f"Error: {str(e)}")
443444
raise typer.Exit(1) from e
445+
except SearchClientError as e:
446+
if output_format == OutputFormat.JSON:
447+
message, error_type, log_path = json_error_payload(e, command="dates")
448+
payload = build_json_error_response(
449+
search_type="dates",
450+
message=message,
451+
error_type=error_type,
452+
)
453+
payload["error"]["log_path"] = str(log_path)
454+
emit_json(payload)
455+
raise typer.Exit(1) from e
456+
raise report_cli_error(e, command="dates") from e
444457
except (AttributeError, ValueError) as e:
445458
if "module 'fli.search' has no attribute 'SearchDates'" in str(e):
446459
raise
@@ -484,3 +497,15 @@ def dates(
484497
raise typer.Exit(1) from e
485498
typer.echo(f"Error: {str(e)}")
486499
raise typer.Exit(1) from e
500+
except Exception as e: # noqa: BLE001 — fall back to clean reporting
501+
if output_format == OutputFormat.JSON:
502+
message, error_type, log_path = json_error_payload(e, command="dates")
503+
payload = build_json_error_response(
504+
search_type="dates",
505+
message=message,
506+
error_type=error_type,
507+
)
508+
payload["error"]["log_path"] = str(log_path)
509+
emit_json(payload)
510+
raise typer.Exit(1) from e
511+
raise report_cli_error(e, command="dates") from e

fli/cli/commands/flights.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import typer
66

77
from fli.cli.enums import OutputFormat
8+
from fli.cli.errors import json_error_payload, report_cli_error
89
from fli.cli.utils import (
910
build_json_error_response,
1011
build_json_success_response,
@@ -32,7 +33,7 @@
3233
LayoverRestrictions,
3334
PassengerInfo,
3435
)
35-
from fli.search import SearchFlights
36+
from fli.search import SearchClientError, SearchFlights
3637

3738

3839
def _search_flights_core(
@@ -231,6 +232,32 @@ def _search_flights_core(
231232

232233
typer.echo(f"Error: {str(e)}")
233234
raise typer.Exit(1) from e
235+
except SearchClientError as e:
236+
if output_format == OutputFormat.JSON:
237+
message, error_type, log_path = json_error_payload(e, command="flights")
238+
payload = build_json_error_response(
239+
search_type="flights",
240+
message=message,
241+
error_type=error_type,
242+
query=query,
243+
)
244+
payload["error"]["log_path"] = str(log_path)
245+
emit_json(payload)
246+
raise typer.Exit(1) from e
247+
raise report_cli_error(e, command="flights") from e
248+
except Exception as e: # noqa: BLE001 — fall back to clean reporting
249+
if output_format == OutputFormat.JSON:
250+
message, error_type, log_path = json_error_payload(e, command="flights")
251+
payload = build_json_error_response(
252+
search_type="flights",
253+
message=message,
254+
error_type=error_type,
255+
query=query,
256+
)
257+
payload["error"]["log_path"] = str(log_path)
258+
emit_json(payload)
259+
raise typer.Exit(1) from e
260+
raise report_cli_error(e, command="flights") from e
234261

235262

236263
def flights(

fli/cli/commands/multi.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import typer
77

8+
from fli.cli.errors import report_cli_error
89
from fli.cli.utils import display_flight_results, validate_time_range
910
from fli.core import (
1011
build_multi_city_segments,
@@ -21,7 +22,7 @@
2122
PassengerInfo,
2223
TimeRestrictions,
2324
)
24-
from fli.search import SearchFlights
25+
from fli.search import SearchClientError, SearchFlights
2526

2627
LEG_PATTERN = re.compile(r"^([A-Za-z]{3}),([A-Za-z]{3}),(\d{4}-\d{1,2}-\d{1,2})$")
2728

@@ -165,3 +166,7 @@ def multi(
165166
except (AttributeError, ValueError) as e:
166167
typer.echo(f"Error: {str(e)}")
167168
raise typer.Exit(1) from e
169+
except SearchClientError as e:
170+
raise report_cli_error(e, command="multi") from e
171+
except Exception as e: # noqa: BLE001 — fall back to clean reporting
172+
raise report_cli_error(e, command="multi") from e

fli/cli/errors.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""CLI error reporting helpers.
2+
3+
Turns ugly tracebacks into a one-line message for the user plus a
4+
self-contained log file under ~/.fli/logs/ that captures the full
5+
traceback for debugging.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import logging
11+
import sys
12+
import traceback
13+
from datetime import datetime, timezone
14+
from pathlib import Path
15+
16+
import typer
17+
18+
from fli.cli.console import console
19+
from fli.search.exceptions import (
20+
SearchClientError,
21+
SearchConnectionError,
22+
SearchHTTPError,
23+
SearchTimeoutError,
24+
)
25+
26+
_LOG_DIR = Path.home() / ".fli" / "logs"
27+
_logger = logging.getLogger("fli")
28+
29+
30+
def _friendly_message(exc: BaseException) -> str:
31+
"""Return the short, user-facing message for ``exc``."""
32+
if isinstance(exc, SearchTimeoutError):
33+
return f"Request timed out. {exc}"
34+
if isinstance(exc, SearchConnectionError):
35+
return f"Network error. {exc}"
36+
if isinstance(exc, SearchHTTPError):
37+
return f"Google Flights error. {exc}"
38+
if isinstance(exc, SearchClientError):
39+
return f"Search failed. {exc}"
40+
return f"Unexpected error: {exc.__class__.__name__}: {exc}"
41+
42+
43+
def _write_log(exc: BaseException, *, command: str | None = None) -> Path:
44+
"""Write the full traceback for ``exc`` to a log file and return the path."""
45+
_LOG_DIR.mkdir(parents=True, exist_ok=True)
46+
# Microsecond precision so rapid-fire errors (e.g. tests, parallel
47+
# legs) don't collide on the same filename and silently overwrite.
48+
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
49+
log_path = _LOG_DIR / f"fli-error-{timestamp}.log"
50+
51+
lines: list[str] = []
52+
lines.append(f"timestamp: {datetime.now(timezone.utc).isoformat()}")
53+
if command:
54+
lines.append(f"command: {command}")
55+
lines.append(f"argv: {sys.argv}")
56+
lines.append(f"error_type: {exc.__class__.__module__}.{exc.__class__.__name__}")
57+
lines.append(f"error_message: {exc}")
58+
lines.append("")
59+
lines.append("traceback:")
60+
lines.append("".join(traceback.format_exception(type(exc), exc, exc.__traceback__)))
61+
62+
log_path.write_text("\n".join(lines), encoding="utf-8")
63+
return log_path
64+
65+
66+
def report_cli_error(
67+
exc: BaseException,
68+
*,
69+
command: str | None = None,
70+
exit_code: int = 1,
71+
) -> typer.Exit:
72+
"""Print a clean message for ``exc``, write a log file, and return a ``typer.Exit``.
73+
74+
Callers should ``raise`` the returned :class:`typer.Exit` so typer
75+
handles the exit code (and the original exception is suppressed from
76+
the user's terminal).
77+
"""
78+
log_path = _write_log(exc, command=command)
79+
message = _friendly_message(exc)
80+
81+
console.print(f"[red]Error:[/red] {message}")
82+
console.print(f"[dim]Full traceback written to {log_path}[/dim]")
83+
84+
# Still log at debug for anyone who wired up python logging.
85+
_logger.debug("CLI error", exc_info=exc)
86+
87+
return typer.Exit(exit_code)
88+
89+
90+
def json_error_payload(exc: BaseException, *, command: str | None = None) -> tuple[str, str, Path]:
91+
"""Return ``(message, error_type, log_path)`` for JSON-mode error output."""
92+
log_path = _write_log(exc, command=command)
93+
if isinstance(exc, SearchTimeoutError):
94+
return str(exc), "timeout", log_path
95+
if isinstance(exc, SearchConnectionError):
96+
return str(exc), "connection_error", log_path
97+
if isinstance(exc, SearchHTTPError):
98+
return str(exc), "http_error", log_path
99+
if isinstance(exc, SearchClientError):
100+
return str(exc), "search_error", log_path
101+
return f"{exc.__class__.__name__}: {exc}", "unexpected_error", log_path

fli/search/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,18 @@
11
from .dates import DatePrice, SearchDates
2+
from .exceptions import (
3+
SearchClientError,
4+
SearchConnectionError,
5+
SearchHTTPError,
6+
SearchTimeoutError,
7+
)
28
from .flights import SearchFlights
39

410
__all__ = [
511
"SearchFlights",
612
"SearchDates",
713
"DatePrice",
14+
"SearchClientError",
15+
"SearchTimeoutError",
16+
"SearchConnectionError",
17+
"SearchHTTPError",
818
]

fli/search/client.py

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@
2626
from tenacity import retry, stop_after_attempt, wait_exponential
2727

2828
from fli.search._concurrency import TokenBucketRateLimiter
29+
from fli.search.exceptions import (
30+
SearchClientError,
31+
SearchConnectionError,
32+
SearchHTTPError,
33+
SearchTimeoutError,
34+
)
2935

3036
# ``curl_cffi`` adds ~100ms to import time on first load — we only need
3137
# it once an HTTP request actually fires, so import lazily on first use.
@@ -110,7 +116,7 @@ def get(self, url: str, **kwargs: Any) -> Response:
110116
response.raise_for_status()
111117
return response
112118
except Exception as e:
113-
raise Exception(f"GET request failed: {str(e)}") from e
119+
raise _wrap_request_error("GET", url, e) from e
114120

115121
@retry(stop=stop_after_attempt(3), wait=wait_exponential(), reraise=True)
116122
def post(self, url: str, **kwargs: Any) -> Response:
@@ -121,7 +127,61 @@ def post(self, url: str, **kwargs: Any) -> Response:
121127
response.raise_for_status()
122128
return response
123129
except Exception as e:
124-
raise Exception(f"POST request failed: {str(e)}") from e
130+
raise _wrap_request_error("POST", url, e) from e
131+
132+
133+
def _wrap_request_error(method: str, url: str, exc: BaseException) -> SearchClientError:
134+
"""Map curl-cffi / network errors into our typed ``SearchClientError`` family.
135+
136+
The CLI surfaces these as short user-facing messages and writes the
137+
underlying traceback to a log file, so the message here should read
138+
well on its own.
139+
"""
140+
# If a typed error somehow escapes the request body (e.g. a future
141+
# change in ``_session()``), keep its original type instead of
142+
# downgrading it to the generic fallback below.
143+
if isinstance(exc, SearchClientError):
144+
return exc
145+
146+
# Imported lazily — ``curl_cffi.requests.exceptions`` triggers the
147+
# full curl-cffi load, which we otherwise defer until first request.
148+
from curl_cffi.requests import exceptions as curl_exc
149+
150+
host = _host_from_url(url)
151+
if isinstance(exc, curl_exc.Timeout):
152+
return SearchTimeoutError(
153+
f"Timed out talking to Google Flights ({host}). "
154+
"The service may be slow or unreachable from your network — "
155+
"check your connection and try again."
156+
)
157+
if isinstance(exc, curl_exc.ConnectionError):
158+
return SearchConnectionError(
159+
f"Could not reach Google Flights ({host}). "
160+
"Check your internet connection or DNS and try again."
161+
)
162+
if isinstance(exc, curl_exc.HTTPError):
163+
status = getattr(getattr(exc, "response", None), "status_code", None)
164+
suffix = f" (HTTP {status})" if status else ""
165+
return SearchHTTPError(
166+
f"Google Flights returned an error response{suffix}. "
167+
"The request may be malformed, rate-limited, or blocked.",
168+
status_code=status,
169+
)
170+
# Anything else (including bare CurlError) — keep a clean message but
171+
# preserve the original via ``__cause__`` so logs still show details.
172+
return SearchClientError(
173+
f"{method} request to Google Flights ({host}) failed: {exc.__class__.__name__}"
174+
)
175+
176+
177+
def _host_from_url(url: str) -> str:
178+
"""Best-effort host extraction for error messages."""
179+
try:
180+
from urllib.parse import urlparse
181+
182+
return urlparse(url).hostname or url
183+
except Exception: # noqa: BLE001 — never let logging fail the request path
184+
return url
125185

126186

127187
def get_client() -> Client:

fli/search/exceptions.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""Typed errors raised by the search client.
2+
3+
These exist so the CLI (and library consumers) can react to network
4+
failures with a clear, user-facing message instead of a raw curl-cffi
5+
traceback. They are intentionally light wrappers — the original
6+
exception is kept as ``__cause__`` for logging.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
12+
class SearchClientError(Exception):
13+
"""Base class for errors talking to the Google Flights backend."""
14+
15+
16+
class SearchTimeoutError(SearchClientError):
17+
"""The request to Google Flights timed out before any data arrived."""
18+
19+
20+
class SearchConnectionError(SearchClientError):
21+
"""A network/DNS issue prevented us from reaching Google Flights."""
22+
23+
24+
class SearchHTTPError(SearchClientError):
25+
"""Google Flights returned a non-2xx HTTP response."""
26+
27+
def __init__(self, message: str, *, status_code: int | None = None):
28+
"""Store the HTTP status alongside the message for richer logging."""
29+
super().__init__(message)
30+
self.status_code = status_code

0 commit comments

Comments
 (0)