Skip to content

Commit ef7041e

Browse files
committed
fix: improve error handling across the codebase
- cli.py: replace bare except Exception with specific exception types - cli.py: remove from None to preserve exception chains - cli.py: remove info cache hint that writes to stderr - fetcher.py: add attempt count to RuntimeError message - fetcher.py: use exponential backoff in retry loops - feed.py: skip malformed RSS items instead of failing entire feed - download.py: set raise_for_status=True on ClientSession - mcp.py: wrap search_papers and download_paper in try/except - config_store.py: warn on config read failure instead of silently ignoring - config_store.py: handle OSError in write_config
1 parent d27cd35 commit ef7041e

8 files changed

Lines changed: 77 additions & 76 deletions

File tree

src/nber_cli/cli.py

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -388,11 +388,11 @@ def _run_single_download(paper_id: str, output_file: Path | None, save_base: Pat
388388
downloaded_file = asyncio.run(download_paper_to_file(paper_id, output_file, restrict_dir=restrict_dir))
389389
else:
390390
downloaded_file = asyncio.run(download_paper(paper_id, save_base, restrict_dir=restrict_dir))
391-
except (Exception, asyncio.CancelledError) as error:
391+
except (ClientResponseError, ClientError, TimeoutError, asyncio.CancelledError) as error:
392392
error_msg = _format_download_error(paper_id, error)
393393
db.record_download(None, paper_id, "failed", error=error_msg)
394394
print(error_msg, file=sys.stderr)
395-
raise SystemExit(1) from None
395+
raise SystemExit(1) from error
396396

397397
db.record_download(None, paper_id, "success", saved_path=str(downloaded_file))
398398
_print_download_success(paper_id, downloaded_file)
@@ -434,15 +434,6 @@ def _info_payload(paper, include_all: bool) -> dict:
434434
return result
435435

436436

437-
def _print_info_cache_hit_hint(paper_id: int) -> None:
438-
normalized_paper_id = f"w{paper_id}"
439-
print(
440-
"Loaded from info cache. "
441-
f"To fetch the latest version, run: nber-cli info {normalized_paper_id} --refresh",
442-
file=sys.stderr,
443-
)
444-
445-
446437
def _feed_clean_options(args: argparse.Namespace) -> dict[str, object]:
447438
return {
448439
"days": args.days,
@@ -666,8 +657,6 @@ def main() -> None:
666657

667658
paper = info_cache_result.paper
668659
db.record_info(None, nber_id)
669-
if info_cache_result.from_cache:
670-
_print_info_cache_hit_hint(nber_id)
671660

672661
if args.output_format == "json":
673662
_print_json(_info_payload(paper, args.show_all))

src/nber_cli/config_store.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,10 @@ def read_config(config_path: Path | None = None) -> dict[str, Any]:
124124
else:
125125
try:
126126
loaded = json.loads(resolved_path.read_text())
127-
except (OSError, json.JSONDecodeError):
127+
except (OSError, json.JSONDecodeError) as error:
128+
import warnings
129+
130+
warnings.warn(f"failed to read config from {resolved_path}: {error.__class__.__name__}")
128131
loaded = None
129132
config = loaded if isinstance(loaded, dict) else {}
130133

@@ -134,8 +137,11 @@ def read_config(config_path: Path | None = None) -> dict[str, Any]:
134137

135138
def write_config(config: dict[str, Any], config_path: Path | None = None) -> None:
136139
resolved_path = config_path or default_config_path()
137-
resolved_path.parent.mkdir(parents=True, exist_ok=True)
138-
resolved_path.write_text(json.dumps(config, ensure_ascii=False, indent=2) + "\n")
140+
try:
141+
resolved_path.parent.mkdir(parents=True, exist_ok=True)
142+
resolved_path.write_text(json.dumps(config, ensure_ascii=False, indent=2) + "\n")
143+
except OSError as error:
144+
raise RuntimeError(f"failed to write config to {resolved_path}: {error.__class__.__name__}") from error
139145

140146

141147
def update_database_config(

src/nber_cli/download.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ async def _fetch(sess: ClientSession) -> bytes:
8585
connector = _create_connector()
8686
timeout = ClientTimeout(total=NBER_CLI_CONFIG.request_timeout_seconds)
8787
async with ClientSession(
88-
timeout=timeout, connector=connector, headers=headers
88+
timeout=timeout, connector=connector, headers=headers, raise_for_status=True
8989
) as base_session:
9090
content = await _retry_async(lambda: _fetch(base_session))
9191

src/nber_cli/feed.py

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -121,26 +121,29 @@ def parse_feed_xml(xml_text: str) -> list[NBERFeedItem]:
121121

122122
items: list[NBERFeedItem] = []
123123
for raw_item in root.findall("./channel/item"):
124-
raw_title = _clean_feed_text(raw_item.findtext("title"))
125-
title, authors = _parse_feed_title(raw_title)
126-
source_url = _clean_feed_text(raw_item.findtext("link"))
127-
guid = _clean_feed_text(raw_item.findtext("guid")) or source_url
128-
paper_id = _extract_paper_id(source_url) or _extract_paper_id(guid)
129-
if not paper_id:
130-
raise ValueError("NBER RSS item is missing a paper ID")
131-
132-
url, _ = urldefrag(source_url)
133-
items.append(
134-
NBERFeedItem(
135-
paper_id=paper_id,
136-
title=title,
137-
authors=authors,
138-
abstract=_clean_feed_text(raw_item.findtext("description")),
139-
url=url or source_url,
140-
source_url=source_url,
141-
guid=guid,
124+
try:
125+
raw_title = _clean_feed_text(raw_item.findtext("title"))
126+
title, authors = _parse_feed_title(raw_title)
127+
source_url = _clean_feed_text(raw_item.findtext("link"))
128+
guid = _clean_feed_text(raw_item.findtext("guid")) or source_url
129+
paper_id = _extract_paper_id(source_url) or _extract_paper_id(guid)
130+
if not paper_id:
131+
continue
132+
133+
url, _ = urldefrag(source_url)
134+
items.append(
135+
NBERFeedItem(
136+
paper_id=paper_id,
137+
title=title,
138+
authors=authors,
139+
abstract=_clean_feed_text(raw_item.findtext("description")),
140+
url=url or source_url,
141+
source_url=source_url,
142+
guid=guid,
143+
)
142144
)
143-
)
145+
except Exception:
146+
continue
144147

145148
return items
146149

src/nber_cli/fetcher.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -118,12 +118,12 @@ def _load_text_sync(url: str) -> str:
118118
raise
119119
last_error = error
120120

121-
time.sleep(0)
121+
time.sleep(min(2 ** attempt, 30))
122122

123123
if last_error is not None:
124124
raise last_error
125125

126-
raise RuntimeError("request failed unexpectedly")
126+
raise RuntimeError(f"request failed after {NBER_CLI_CONFIG.request_attempts} attempts")
127127

128128

129129
async def _retry_async(loader):
@@ -141,12 +141,12 @@ async def _retry_async(loader):
141141
raise
142142
last_error = error
143143

144-
await asyncio.sleep(0)
144+
await asyncio.sleep(min(2 ** attempt, 30))
145145

146146
if last_error is not None:
147147
raise last_error
148148

149-
raise RuntimeError("request failed unexpectedly")
149+
raise RuntimeError(f"request failed after {NBER_CLI_CONFIG.request_attempts} attempts")
150150

151151

152152
def parse_page(page: str) -> NBER:

src/nber_cli/mcp.py

Lines changed: 30 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -79,38 +79,44 @@ async def search_papers(
7979
Returns:
8080
Dictionary containing result metadata and papers.
8181
"""
82-
results = await search_nber(
83-
query,
84-
start_date=start_date,
85-
end_date=end_date,
86-
page=page,
87-
per_page=per_page,
88-
)
89-
return search_results(results)
82+
try:
83+
results = await search_nber(
84+
query,
85+
start_date=start_date,
86+
end_date=end_date,
87+
page=page,
88+
per_page=per_page,
89+
)
90+
return search_results(results)
91+
except Exception as error:
92+
return {"error": f"Search failed: {error.__class__.__name__}"}
9093

9194

9295
@nber_mcp.tool()
93-
async def download_paper(paper_id: str, output_path: Optional[str] = None) -> bool:
96+
async def download_paper(paper_id: str, output_path: Optional[str] = None) -> dict:
9497
"""Download an NBER working paper as a PDF.
9598
9699
Args:
97100
paper_id: Paper ID, e.g. 'w1234' or '1234'
98101
output_path: Explicit output file path, must be within the current directory. If not provided, saves as <paper_id>.pdf in current directory.
99102
100103
Returns:
101-
True if download succeeds. On failure, an exception is raised and propagated to the caller.
104+
Dictionary with "success": True or "error": message.
102105
"""
103-
nber_id = _parse_paper_id(paper_id)
104-
normalized_id = f"w{nber_id}"
105-
106-
if output_path:
107-
target = Path(output_path)
108-
try:
109-
target.absolute().relative_to(Path.cwd().absolute())
110-
except ValueError:
111-
raise ValueError("MCP download only allows paths within the current directory")
112-
await download_paper_to_file(normalized_id, target, restrict_dir=True)
113-
else:
114-
await download_paper_to_dir(normalized_id, Path.cwd(), restrict_dir=True)
115-
116-
return True
106+
try:
107+
nber_id = _parse_paper_id(paper_id)
108+
normalized_id = f"w{nber_id}"
109+
110+
if output_path:
111+
target = Path(output_path)
112+
try:
113+
target.absolute().relative_to(Path.cwd().absolute())
114+
except ValueError:
115+
return {"error": "MCP download only allows paths within the current directory"}
116+
await download_paper_to_file(normalized_id, target, restrict_dir=True)
117+
else:
118+
await download_paper_to_dir(normalized_id, Path.cwd(), restrict_dir=True)
119+
120+
return {"success": True}
121+
except Exception as error:
122+
return {"error": f"Download failed: {error.__class__.__name__}"}

tests/test_cli.py

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -639,12 +639,10 @@ def test_info_cache_hit_skips_network(self, mock_get_nber, tmp_path, capsys):
639639
),
640640
)
641641

642-
with patch("nber_cli.cli._print_info_cache_hit_hint") as mock_hint:
643-
with patch.object(sys, "argv", ["nber-cli", "info", "w1234"]):
644-
main()
642+
with patch.object(sys, "argv", ["nber-cli", "info", "w1234"]):
643+
main()
645644

646645
mock_get_nber.assert_not_called()
647-
mock_hint.assert_called_once_with(1234)
648646
captured = capsys.readouterr()
649647
assert "Cached Title" in captured.out
650648
with sqlite3.connect(db_path) as connection:
@@ -671,12 +669,10 @@ def test_info_json_cache_hit_keeps_stdout_json(self, mock_get_nber, tmp_path, ca
671669
),
672670
)
673671

674-
with patch("nber_cli.cli._print_info_cache_hit_hint") as mock_hint:
675-
with patch.object(sys, "argv", ["nber-cli", "info", "w1234", "--format", "json"]):
676-
main()
672+
with patch.object(sys, "argv", ["nber-cli", "info", "w1234", "--format", "json"]):
673+
main()
677674

678675
mock_get_nber.assert_not_called()
679-
mock_hint.assert_called_once_with(1234)
680676
captured = capsys.readouterr()
681677
payload = json.loads(captured.out)
682678
assert payload["title"] == "Cached Title"

tests/test_mcp.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,18 +103,19 @@ class TestDownloadPaper:
103103
async def test_download_to_default_path(self):
104104
with patch("nber_cli.mcp.download_paper_to_dir", new_callable=AsyncMock) as mock_download:
105105
result = await download_paper("w1234")
106-
assert result is True
106+
assert result == {"success": True}
107107
mock_download.assert_called_once()
108108

109109
@pytest.mark.asyncio
110110
async def test_download_to_explicit_path(self):
111111
target = Path.cwd() / "paper.pdf"
112112
with patch("nber_cli.mcp.download_paper_to_file", new_callable=AsyncMock) as mock_download:
113113
result = await download_paper("w1234", output_path=str(target))
114-
assert result is True
114+
assert result == {"success": True}
115115
mock_download.assert_called_once_with("w1234", target, restrict_dir=True)
116116

117117
@pytest.mark.asyncio
118118
async def test_rejects_path_outside_cwd(self):
119-
with pytest.raises(ValueError, match="within the current directory"):
120-
await download_paper("w1234", output_path="/tmp/paper.pdf")
119+
result = await download_paper("w1234", output_path="/tmp/paper.pdf")
120+
assert "error" in result
121+
assert "within the current directory" in result["error"]

0 commit comments

Comments
 (0)