Skip to content
This repository was archived by the owner on Mar 22, 2025. It is now read-only.

Commit 80e41d8

Browse files
authored
fix: Improve failure handling and logging (#189)
1 parent 8e2fb0f commit 80e41d8

File tree

10 files changed

+100
-115
lines changed

10 files changed

+100
-115
lines changed

podcast_archiver/base.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,7 @@
33
import xml.etree.ElementTree as etree
44
from typing import TYPE_CHECKING
55

6-
from rich import print as rprint
7-
8-
from podcast_archiver.logging import logger
6+
from podcast_archiver.logging import logger, rprint
97
from podcast_archiver.processor import FeedProcessor
108

119
if TYPE_CHECKING:

podcast_archiver/cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,8 +283,8 @@ def generate_default_config(ctx: click.Context, param: click.Parameter, value: b
283283
)
284284
@click.pass_context
285285
def main(ctx: click.RichContext, /, **kwargs: Any) -> int:
286+
get_console().quiet = kwargs["quiet"]
286287
configure_logging(kwargs["verbose"])
287-
get_console().quiet = kwargs["quiet"] or kwargs["verbose"] > 1
288288
try:
289289
settings = Settings.load_from_dict(kwargs)
290290

podcast_archiver/download.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@ def __call__(self) -> EpisodeResult:
5959
try:
6060
return self.run()
6161
except Exception as exc:
62-
logger.error("Download failed", exc_info=exc)
62+
logger.error(f"Download failed: {exc}")
63+
logger.debug("Exception while downloading", exc_info=exc)
6364
return EpisodeResult(self.episode, DownloadResult.FAILED)
6465

6566
def run(self) -> EpisodeResult:
@@ -73,7 +74,6 @@ def run(self) -> EpisodeResult:
7374
self.episode.enclosure.href,
7475
stream=True,
7576
allow_redirects=True,
76-
timeout=constants.REQUESTS_TIMEOUT,
7777
)
7878
response.raise_for_status()
7979
total_size = int(response.headers.get("content-length", "0"))

podcast_archiver/logging.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,25 @@
22

33
import logging
44
import logging.config
5+
from typing import Any
56

67
from rich import get_console
8+
from rich import print as _print
79
from rich.logging import RichHandler
810

911
logger = logging.getLogger("podcast_archiver")
1012

1113

14+
def rprint(*objects: Any, sep: str = " ", end: str = "\n", **kwargs: Any) -> None:
15+
if logger.level == logging.NOTSET or logger.level >= logging.WARNING:
16+
_print(*objects, sep=sep, end=end, **kwargs)
17+
return
18+
logger.info(objects[0].strip(), *objects[1:])
19+
20+
1221
def configure_logging(verbosity: int) -> None:
13-
if verbosity > 2:
22+
if verbosity > 1:
1423
level = logging.DEBUG
15-
elif verbosity == 2:
16-
level = logging.INFO
1724
elif verbosity == 1:
1825
level = logging.WARNING
1926
else:
@@ -35,4 +42,5 @@ def configure_logging(verbosity: int) -> None:
3542
)
3643
],
3744
)
45+
logger.setLevel(level)
3846
logger.debug("Running in debug mode.")

podcast_archiver/processor.py

Lines changed: 11 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,12 @@
55
from threading import Event
66
from typing import TYPE_CHECKING
77

8-
from pydantic import ValidationError
9-
from requests import HTTPError
10-
from rich import print as rprint
11-
128
from podcast_archiver.download import DownloadJob
139
from podcast_archiver.enums import DownloadResult, QueueCompletionType
14-
from podcast_archiver.logging import logger
10+
from podcast_archiver.logging import logger, rprint
1511
from podcast_archiver.models import Episode, Feed, FeedInfo
1612
from podcast_archiver.types import EpisodeResult, EpisodeResultsList
17-
from podcast_archiver.utils import FilenameFormatter
13+
from podcast_archiver.utils import FilenameFormatter, handle_feed_request
1814

1915
if TYPE_CHECKING:
2016
from pathlib import Path
@@ -48,25 +44,15 @@ def __init__(self, settings: Settings) -> None:
4844

4945
def process(self, url: str) -> ProcessingResult:
5046
result = ProcessingResult()
51-
try:
52-
feed = Feed.from_url(url)
53-
except HTTPError as exc:
54-
if exc.response is not None:
55-
rprint(f"[error]Received status code {exc.response.status_code} from {url}[/]")
56-
logger.debug("Failed to request feed url %s", url, exc_info=exc)
57-
return result
58-
except ValidationError as exc:
59-
logger.debug("Invalid feed", exc_info=exc)
60-
rprint(f"[error]Received invalid feed from {url}[/]")
61-
return result
62-
63-
result.feed = feed
64-
rprint(f"\n[bold bright_magenta]Downloading archive for: {feed.info.title}[/]\n")
65-
66-
episode_results, completion_msg = self._process_episodes(feed=feed)
67-
self._handle_results(episode_results, result=result)
68-
69-
rprint(f"\n[bar.finished]✔ {completion_msg}[/]")
47+
with handle_feed_request(url):
48+
result.feed = Feed.from_url(url)
49+
50+
if result.feed:
51+
rprint(f"\n[bold bright_magenta]Downloading archive for: {result.feed.info.title}[/]\n")
52+
episode_results, completion_msg = self._process_episodes(feed=result.feed)
53+
self._handle_results(episode_results, result=result)
54+
55+
rprint(f"\n[bar.finished]✔ {completion_msg}[/]")
7056
return result
7157

7258
def _preflight_check(self, episode: Episode, target: Path) -> DownloadResult | None:

podcast_archiver/session.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,33 @@
1-
from requests import Session
1+
from typing import Any
22

3-
from podcast_archiver.constants import USER_AGENT
3+
from requests import PreparedRequest, Session
4+
from requests.adapters import HTTPAdapter
5+
from requests.models import Response as Response
6+
from urllib3.util import Retry
7+
8+
from podcast_archiver.constants import REQUESTS_TIMEOUT, USER_AGENT
9+
10+
11+
class DefaultTimeoutHTTPAdapter(HTTPAdapter):
12+
def send(
13+
self,
14+
request: PreparedRequest,
15+
timeout: None | float | tuple[float, float] | tuple[float, None] = None,
16+
**kwargs: Any,
17+
) -> Response:
18+
return super().send(request, timeout=timeout or REQUESTS_TIMEOUT, **kwargs)
19+
20+
21+
_retries = Retry(
22+
total=3,
23+
connect=1,
24+
backoff_factor=0.5,
25+
status_forcelist=[500, 501, 502, 503, 504],
26+
)
27+
28+
_adapter = DefaultTimeoutHTTPAdapter(max_retries=_retries)
429

530
session = Session()
31+
session.mount("http://", _adapter)
32+
session.mount("https://", _adapter)
633
session.headers.update({"user-agent": USER_AGENT})

podcast_archiver/utils.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@
44
import re
55
from contextlib import contextmanager
66
from string import Formatter
7-
from typing import IO, TYPE_CHECKING, Any, Iterable, Iterator, TypedDict
7+
from typing import IO, TYPE_CHECKING, Any, Generator, Iterable, Iterator, TypedDict
88

9+
from pydantic import ValidationError
10+
from requests import HTTPError
911
from slugify import slugify as _slugify
1012

11-
from podcast_archiver.logging import logger
13+
from podcast_archiver.logging import logger, rprint
1214

1315
if TYPE_CHECKING:
1416
from pathlib import Path
@@ -119,3 +121,24 @@ def atomic_write(target: Path, mode: str = "w") -> Iterator[IO[Any]]:
119121
os.rename(tempfile, target)
120122
finally:
121123
tempfile.unlink(missing_ok=True)
124+
125+
126+
@contextmanager
127+
def handle_feed_request(url: str) -> Generator[None, Any, None]:
128+
try:
129+
yield
130+
except HTTPError as exc:
131+
logger.debug("Failed to request feed url %s", url, exc_info=exc)
132+
if (response := getattr(exc, "response", None)) is None:
133+
rprint(f"[error]Failed to retrieve feed {url}: {exc}[/]")
134+
return
135+
136+
rprint(f"[error]Received status code {response.status_code} from {url}[/]")
137+
138+
except ValidationError as exc:
139+
logger.debug("Feed validation failed for %s", url, exc_info=exc)
140+
rprint(f"[error]Received invalid feed from {url}[/]")
141+
142+
except Exception as exc:
143+
logger.debug("Unexpected error for url %s", url, exc_info=exc)
144+
rprint(f"[error]Failed to retrieve feed {url}: {exc}[/]")

tests/test_download.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,13 +77,22 @@ def test_download_failed(
7777
responses.add(responses.GET, MEDIA_URL, b"BLOB")
7878

7979
job = download.DownloadJob(episode=episode, target=Path("file.mp3"))
80-
with failure_mode(side_effect=side_effect), caplog.at_level(logging.ERROR):
80+
with failure_mode(side_effect=side_effect), caplog.at_level(logging.DEBUG):
8181
result = job()
8282

8383
assert result == (episode, DownloadResult.FAILED)
8484
failure_rec = None
8585
for record in caplog.records:
86-
if record.message == "Download failed":
86+
if record.message.startswith("Download failed: "):
87+
failure_rec = record
88+
break
89+
90+
assert failure_rec
91+
assert not failure_rec.exc_info
92+
93+
failure_rec = None
94+
for record in caplog.records:
95+
if record.message == "Exception while downloading":
8796
failure_rec = record
8897
break
8998

tests/test_filenames.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from podcast_archiver.utils import FilenameFormatter
66

77
FEED_INFO = FeedInfo(
8-
title="That\Show",
8+
title="That\\Show",
99
subtitle="The one that never comes/came to be",
1010
author="TheJanwShow",
1111
language="de-DE",

tests/test_processor.py

Lines changed: 8 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@
99
from podcast_archiver.config import Settings
1010
from podcast_archiver.enums import DownloadResult
1111
from podcast_archiver.models import FeedPage
12-
from podcast_archiver.processor import FeedProcessor
12+
from podcast_archiver.processor import FeedProcessor, ProcessingResult
1313

1414
if TYPE_CHECKING:
1515
from pydantic_core import Url
16+
from responses import RequestsMock
1617

1718

1819
@pytest.mark.parametrize(
@@ -43,78 +44,11 @@ def test_preflight_check(
4344
assert result == expected_result
4445

4546

46-
# def test_download_already_exists(tmp_path_cd: Path, feedobj_lautsprecher_notconsumed: dict[str, Any]) -> None:
47-
# feed = FeedPage.model_validate(feedobj_lautsprecher_notconsumed)
48-
# episode = feed.episodes[0]
49-
50-
# job = download.DownloadJob(episode=episode, target=Path("file.mp3"))
51-
# job.target.parent.mkdir(exist_ok=True)
52-
# job.target.touch()
53-
# result = job()
54-
55-
# assert result == (episode, DownloadResult.ALREADY_EXISTS)
56-
57-
58-
# def test_download_aborted(tmp_path_cd: Path, feedobj_lautsprecher: dict[str, Any]) -> None:
59-
# feed = FeedPage.model_validate(feedobj_lautsprecher)
60-
# episode = feed.episodes[0]
61-
62-
# job = download.DownloadJob(episode=episode, target=Path("file.mp3"))
63-
# job.stop_event.set()
64-
# result = job()
65-
66-
# assert result == (episode, DownloadResult.ABORTED)
67-
68-
69-
# class PartialObjectMock(Protocol):
70-
# def __call__(self, side_effect: type[Exception]) -> mock.Mock: ...
71-
72-
73-
# # mypy: disable-error-code="attr-defined"
74-
# @pytest.mark.parametrize(
75-
# "failure_mode, side_effect, should_download",
76-
# [
77-
# (partial(mock.patch.object, download.session, "get"), HTTPError, False),
78-
# (partial(mock.patch.object, utils.os, "fsync"), IOError, True),
79-
# ],
80-
# )
81-
# def test_download_failed(
82-
# tmp_path_cd: Path,
83-
# feedobj_lautsprecher_notconsumed: dict[str, Any],
84-
# failure_mode: PartialObjectMock,
85-
# side_effect: type[Exception],
86-
# caplog: pytest.LogCaptureFixture,
87-
# should_download: bool,
88-
# responses: RequestsMock,
89-
# ) -> None:
90-
# feed = FeedPage.model_validate(feedobj_lautsprecher_notconsumed)
91-
# episode = feed.episodes[0]
92-
# if should_download:
93-
# responses.add(responses.GET, MEDIA_URL, b"BLOB")
94-
95-
# job = download.DownloadJob(episode=episode, target=Path("file.mp3"))
96-
# with failure_mode(side_effect=side_effect), caplog.at_level(logging.ERROR):
97-
# result = job()
98-
99-
# assert result == (episode, DownloadResult.FAILED)
100-
# failure_rec = None
101-
# for record in caplog.records:
102-
# if record.message == "Download failed":
103-
# failure_rec = record
104-
# break
105-
106-
# assert failure_rec
107-
# assert failure_rec.exc_info
108-
# exc_type, _, _ = failure_rec.exc_info
109-
# assert exc_type == side_effect, failure_rec.exc_info
110-
47+
def test_retrieve_failure(responses: RequestsMock) -> None:
48+
settings = Settings()
49+
proc = FeedProcessor(settings)
11150

112-
# @pytest.mark.parametrize("write_info_json", [False, True])
113-
# def test_download_info_json(tmp_path_cd: Path, feedobj_lautsprecher: dict[str, Any], write_info_json: bool) -> None:
114-
# feed = FeedPage.model_validate(feedobj_lautsprecher)
115-
# episode = feed.episodes[0]
116-
# job = download.DownloadJob(episode=episode, target=tmp_path_cd / "file.mp3", write_info_json=write_info_json)
117-
# result = job()
51+
result = proc.process("https://broken.url.invalid")
11852

119-
# assert result == (episode, DownloadResult.COMPLETED_SUCCESSFULLY)
120-
# assert job.infojsonfile.exists() == write_info_json
53+
assert result == ProcessingResult()
54+
assert result.feed is None

0 commit comments

Comments
 (0)