Skip to content

Safely handle metadata plugin exceptions. - #5965

Merged
snejus merged 7 commits into
masterfrom
error_handling_metadata_plugins
Jan 31, 2026
Merged

Safely handle metadata plugin exceptions.#5965
snejus merged 7 commits into
masterfrom
error_handling_metadata_plugins

Conversation

@semohr

@semohr semohr commented Aug 26, 2025

Copy link
Copy Markdown
Contributor

Description

When a metadata plugin raises an exception during the auto-tagger process, the entire operation crashes. This behavior is not desirable, since metadata lookups can legitimately fail for various reasons (e.g., temporary API downtime, network issues, or offline usage).

This PR introduces a safeguard by adding general exception handling around metadata plugin calls. Instead of causing the whole process to fail, exceptions from individual plugins are now caught and logged. This ensures that the auto-tagger continues to function with the remaining available metadata sources. I used a proxy pattern here as this
seems like an elegant solution to me.

This replaces the efforts from #5910

Decisions needed

How do we want to name the configuration option which controls if an error should be propagated and also where/how do we want this to be documented?

Todos:

  • Changelog.
  • Documetnation.

@github-actions

This comment was marked as resolved.

@codecov

codecov Bot commented Aug 26, 2025

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.24390% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 68.95%. Comparing base (0cde654) to head (2196bd8).
⚠️ Report is 8 commits behind head on master.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
beets/metadata_plugins.py 89.74% 4 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #5965      +/-   ##
==========================================
+ Coverage   68.87%   68.95%   +0.08%     
==========================================
  Files         140      140              
  Lines       18666    18685      +19     
  Branches     3063     3058       -5     
==========================================
+ Hits        12856    12885      +29     
+ Misses       5162     5153       -9     
+ Partials      648      647       -1     
Files with missing lines Coverage Δ
beets/plugins.py 87.60% <100.00%> (ø)
beets/metadata_plugins.py 76.11% <89.74%> (+12.64%) ⬆️
🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@semohr semohr added this to the 2.4.1 milestone Aug 26, 2025
@semohr

semohr commented Aug 26, 2025

Copy link
Copy Markdown
Contributor Author

Related:

@semohr
semohr force-pushed the error_handling_metadata_plugins branch 2 times, most recently from cbf389c to 88741c3 Compare September 9, 2025 13:19
@semohr
semohr marked this pull request as ready for review September 9, 2025 13:39
Copilot AI review requested due to automatic review settings September 9, 2025 13:39
@semohr
semohr requested a review from a team as a code owner September 9, 2025 13:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR adds exception handling to metadata plugin operations to prevent crashes during the auto-tagger process. When individual metadata plugins encounter errors, the system will now log the issues and continue processing with other available metadata sources instead of failing completely.

  • Introduces safe wrapper functions (_safe_call and _safe_yield_from) around metadata plugin method calls
  • Updates all plugin interface methods to use exception handling
  • Adds comprehensive test coverage for error scenarios in metadata plugins

Reviewed Changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

File Description
beets/metadata_plugins.py Core implementation of exception handling with safe wrapper functions and updated plugin interface calls
test/test_metadata_plugins.py New test file with mock error plugin and comprehensive test coverage for all metadata plugin methods
docs/changelog.rst Documentation of the bug fix in the changelog

Tip: Customize your code reviews with copilot-instructions.md. Create the file or learn how to get started.

Comment thread beets/metadata_plugins.py Outdated
Comment thread beets/metadata_plugins.py Outdated
Comment thread beets/metadata_plugins.py Outdated
@sourcery-ai

sourcery-ai Bot commented Sep 9, 2025

Copy link
Copy Markdown
Contributor

Reviewer's Guide

This PR implements robust exception handling around metadata plugin calls by introducing safe-call and safe-yield helpers with logging, updating all plugin invocations to use these wrappers, refining related type signatures, and adding tests and a changelog entry to validate and document the new behavior.

Flow diagram for exception handling in plugin calls

flowchart TD
A["AutoTagger calls plugin method"] --> B{Exception raised?}
B -- Yes --> C["Log error"]
C --> D["Continue to next plugin"]
B -- No --> E["Process plugin result"]
E --> D
Loading

File-Level Changes

Change Details Files
Introduce safe-call and safe-yield wrappers for plugin invocations
  • Add global logger instantiation
  • Define _safe_call, _safe_yield_from, and _class_name_from_method helpers
  • Wrap all metadata plugin calls (candidates, item_candidates, album_for_id, track_for_id, track_distance, album_distance) with the new wrappers
beets/metadata_plugins.py
Refine function signatures and generics for metadata methods
  • Import ParamSpec and TypeVar and reorganize autorestag imports
  • Change candidates return type to Iterator
  • Switch batch lookup parameters from Sequence to Iterable
  • Rename generic type parameter from R to Res for IDResponse
beets/metadata_plugins.py
Update changelog
  • Add entry describing the new exception-handling behavior
docs/changelog.rst
Add tests for plugin exception handling
  • Create test_metadata_plugins.py with a mock plugin that raises errors
  • Verify that errors are caught and logged without crashing the process
test/test_metadata_plugins.py

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey there - I've reviewed your changes - here's some feedback:

  • The log.error calls use curly-brace placeholders but Python’s logging module expects %-style formatting or preformatted strings—update them to use %s or f-strings so the plugin name and exception actually interpolate.
  • As mentioned in the PR limitations, consider adding a config flag to let users opt in to failing on plugin exceptions instead of always swallowing errors.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- The log.error calls use curly-brace placeholders but Python’s logging module expects %-style formatting or preformatted strings—update them to use %s or f-strings so the plugin name and exception actually interpolate.
- As mentioned in the PR limitations, consider adding a config flag to let users opt in to failing on plugin exceptions instead of always swallowing errors.

## Individual Comments

### Comment 1
<location> `test/test_metadata_plugins.py:63` </location>
<code_context>
+        with caplog.at_level("ERROR"):
+            # Call the method to trigger the error
+            ret = getattr(metadata_plugins, method_name)(*args)
+            if isinstance(ret, Iterable):
+                list(ret)
+
</code_context>

<issue_to_address>
Test does not verify that normal (non-error) plugins still work alongside error-raising plugins.

Add a test that registers both an error-raising and a normal plugin to confirm that exceptions in one do not affect the results from others.
</issue_to_address>

### Comment 2
<location> `test/test_metadata_plugins.py:67` </location>
<code_context>
+                list(ret)
+
+            # Check that an error was logged
+            assert len(caplog.records) == 1
+            logs = [record.getMessage() for record in caplog.records]
+            assert logs == ["Error in 'ErrorMetadataMockPlugin': Mocked error"]
</code_context>

<issue_to_address>
Test only checks for error-level logs, but does not verify that debug-level logs (with exception details) are emitted.

Please add assertions to check that debug-level logs with exception details are present, ensuring complete error context is captured.
</issue_to_address>

### Comment 3
<location> `test/test_metadata_plugins.py:69` </location>
<code_context>
+            # Check that an error was logged
+            assert len(caplog.records) == 1
+            logs = [record.getMessage() for record in caplog.records]
+            assert logs == ["Error in 'ErrorMetadataMockPlugin': Mocked error"]
+            caplog.clear()
</code_context>

<issue_to_address>
Test only checks for a single error log per method call, but does not verify that repeated calls do not accumulate unexpected logs.

Add a test to confirm that multiple calls to the method do not produce extra or unexpected log entries.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread test/test_metadata_plugins.py Outdated
Comment thread test/test_metadata_plugins.py Outdated
Comment thread test/test_metadata_plugins.py Outdated
Comment thread test/test_metadata_plugins.py Outdated
@jackwilsdon

jackwilsdon commented Sep 10, 2025

Copy link
Copy Markdown
Contributor

It does feel like we should pause the import until the user has acknowledged the issue (maybe providing options to skip importing the album or continue with existing metadata?), as otherwise you could end up with missing metadata that is awkward to fix later if for example you had network issues or the provider was down.

Not sure how this should interact with --quiet and --quiet-fallback though.

@semohr

semohr commented Sep 10, 2025

Copy link
Copy Markdown
Contributor Author

A prompt makes sense in theory, but I'm really hesitant to add any user interaction because it breaks headless scripts, automation, and make monkey-patching way harder which is a big part of my beets use case. Instead, I'm leaning towards adding a simple config option that lets you tell the plugin to just fail hard if it runs into trouble, so your import would stop and you'd know right away.

On a practical level, I'm also not convinced that skipping these errors is a major issue in the first place. The plugin's job is to suggest candidates, and it's already robust in how it handles failure: if it can't find any candidates, it just doesn't add any, and the import continues with other plugins. Same goes for get_by_id, it's designed to return None, so the higher-level functions are already built to handle that. Imo the introduced behavior should be good enough for most users.

@semohr semohr added the core Pull requests that modify the beets core `beets` label Sep 16, 2025
@semohr semohr modified the milestones: 2.5.0, 2.6.0 Oct 11, 2025

@henry-oberholtzer henry-oberholtzer left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Somehow completely missed this PR when you first put it in - but I've been encountering this exact issue a lot myself and it makes wanting to use batch import really discouraging, especially with the Musicbrainz rate limiting. This is a great fix for it.

@semohr

This comment was marked as outdated.

@semohr
semohr force-pushed the error_handling_metadata_plugins branch 2 times, most recently from 07531a2 to fc600e6 Compare November 3, 2025 14:54
@semohr

semohr commented Nov 3, 2025

Copy link
Copy Markdown
Contributor Author

@henry-oberholtzer I rethought my approach here a bit and opted to use a proxy pattern instead. Seems a bit more clear and maintainable imo.

@semohr

semohr commented Nov 6, 2025

Copy link
Copy Markdown
Contributor Author

@snejus Would you mind having a look here too? I would be happy to know your thoughts on this. Im pretty happy with the general implementation now and can finalize this if we are happy with the approach.

I like the proxy approach (while a bit complex) it is very unintrusive, only hooking into our existing logic at one place.

@henry-oberholtzer

Copy link
Copy Markdown
Member

I'm not the most familiar with proxies, but this looks good to me, same logic, neater application.

@snejus snejus left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a very clean way to handle exceptions, nicely done! See my note about at which point should the new configuration come into play here.

Comment thread beets/metadata_plugins.py Outdated
Comment thread beets/metadata_plugins.py Outdated
Comment thread beets/metadata_plugins.py Outdated
@semohr
semohr force-pushed the error_handling_metadata_plugins branch from f924c5c to a69f575 Compare November 10, 2025 17:44
@snejus

snejus commented Nov 11, 2025

Copy link
Copy Markdown
Member

@semohr As far as I remember this was initially implemented using decorator pattern. Why did you switch to the proxy pattern? I'm most likely missing context, but, I think, the decorator pattern would allow this to be type-checked?

@snejus

snejus commented Nov 12, 2025

Copy link
Copy Markdown
Member

I'm thinking something in these lines indeed

def _safe_call(
    plugin: MetadataSourcePlugin,
    method_name: str,
    *args,
    **kwargs,
):
    """Safely call a plugin method, catching and logging exceptions."""
    try:
        method = getattr(plugin, method_name)
        return method(*args, **kwargs)
    except Exception as e:
        log.error(
            "Error in '{}.{}': {}",
            plugin.data_source,
            method_name,
            e,
        )
        log.debug("Exception details:", exc_info=True)
        return None


@notify_info_yielded("albuminfo_received")
def candidates(*args, **kwargs) -> Iterable[AlbumInfo]:
    """Return matching album candidates from all metadata source plugins."""
    for plugin in find_metadata_source_plugins():
        if result := _safe_call(plugin, "candidates", *args, **kwargs):
            yield from result

Another question: does raise_an_error configuration provide any value? I'd be happy to always handle these exceptions.

@semohr

semohr commented Nov 12, 2025

Copy link
Copy Markdown
Contributor Author

This is mostly how i had it before. This does not catch the errors during the yield and handling this properly requires a bit more work. Also typing is stripped here.

I think retaining the initial behavior is wanted, sometimes one doesn't want to continue if an error occurs as some crucial lookup (for some) might be missing. See also #5965 (comment)

@snejus

snejus commented Nov 15, 2025

Copy link
Copy Markdown
Member

This is mostly how i had it before. This does not catch the errors during the yield and handling this properly requires a bit more work. Also typing is stripped here.

A pure decorator-based approach may be the way to go forward, then:

@contextmanager
def handle_plugin_error(method_name: str):
    """Safely call a plugin method, catching and logging exceptions."""
    try:
        yield
    except Exception as e:
        log.error("Error in '{}': {}", method_name, e)
        log.debug("Exception details:", exc_info=True)


def safe_yield(func: IterF[P, Ret]) -> IterF[P, Ret]:
    @wraps(func)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> Iterable[Ret]:
        with handle_plugin_error(func.__qualname__):
            yield from func(*args, **kwargs)

    return wrapper


@notify_info_yielded("albuminfo_received")
@safe_yield
def candidates(*args, **kwargs) -> Iterable[AlbumInfo]:
    """Return matching album candidates from all metadata source plugins."""
    for plugin in find_metadata_source_plugins():
        yield from plugin.candidates(*args, **kwargs)

To make sure this decorator is suitable for album_for_id and track_for_id, consider using plugin.albums_for_ids and plugin.tracks_for_ids inside those functions. This way, you can also wrap them up notify_info_yielded accordingly.

I think retaining the initial behavior is wanted, sometimes one doesn't want to continue if an error occurs as some crucial lookup (for some) might be missing. See also #5965 (comment)

Presumably, this depends on us handling errors somewhere? Otherwise, right now, an unhandled plugin error kills beets execution.

@semohr

semohr commented Nov 17, 2025

Copy link
Copy Markdown
Contributor Author

Presumably, this depends on us handling errors somewhere? Otherwise, right now, an unhandled plugin error kills beets execution.

This seems like the desired behavior in that case for me. E.g. I want the process to exit completely if musicbrainz is currently down.

To make sure this decorator is suitable for album_for_id and track_for_id, consider using plugin.albums_for_ids and plugin.tracks_for_ids inside those functions. This way, you can also wrap them up notify_info_yielded accordingly.

The issue with that approach is that it does not log the plugin name correctly (here the method_name would be metada_plugins.candidates). Imo it is quite important for the error message to include the actual name of the plugin which has issues. I have really tried quite a few approaches here and this will even get more complex ;)

I can try to restore my initial decorator based approach but it is way more complex and more intrusive. I think the proxy based approach is more maintainable.

@pSpitzner

Copy link
Copy Markdown
Contributor

Hi all,

chiming in because Im currently stumbling across this a lot in beets-flask - hitting API timeouts with musicbrainz every third run or so.

I looked through the current PR state and am wondering what the downside of the proxy pattern is?
As it currently stands we get: typing, user-configurable raising, automatically applies to all plugins, correctly logs plugin-name to global logger.

The only downside I could imagine is, that the ProxyClass has to be updated when new abstract methods are added to the MetaPlugins, but I suppose this is in some form also the case with the decorators.

@snejus

snejus commented Jan 9, 2026

Copy link
Copy Markdown
Member

I looked through the current PR state and am wondering what the downside of the proxy pattern is?

  • Proxy hides behavior from plugin maintainers; the base class is empty so the error handling is invisible.
  • Introspection/debugging regresses: mbp = MusicBrainzPlugin(); mbp.candidates?? points at SafeProxy, not the plugin.
  • Changes object identity (SafeProxy instead of the real plugin), which can break isinstance/__class__ assumptions.
  • Config is cached via find_metadata_source_plugins; toggling raise_on_error will not take effect until cache clear.
  • This feels non-Pythonic vs explicit wrappers, and the typing is not transparent (the "fake subclass" hack is a maintainability smell).

@semohr

semohr commented Jan 9, 2026

Copy link
Copy Markdown
Contributor Author

Proxy hides behavior from plugin maintainers; the base class is empty so the error handling is invisible.

The proxy is only ever used in the metadata_plugins.py file. Plugin developers should not interact with it in any way as far as I can see.

Config is cached via find_metadata_source_plugins; toggling raise_on_error will not take effect until cache clear.

Isn't this the case for all config values in beets? Once they are loaded with confuse, future calls to the same confuse object will always return the value in memory. I think having a live relodable config is out of scope here.

This feels non-Pythonic vs explicit wrappers, and the typing is not transparent (the "fake subclass" hack is a maintainability smell).

Agreed it does not feel too pythonic. Feelings aside tho, it does work and is pretty nonintrussive which is a big plus from the maintainability side for me.


We might want to create a full decorator based implementation to compare in another branch. Im traveling currently but might come around it, I guees latest in a month or so.

@snejus

snejus commented Jan 9, 2026

Copy link
Copy Markdown
Member

Decorator-based approach that should satisfy each of our requirements:

@contextmanager
def handle_plugin_error(plugin: MetadataSourcePlugin, method_name: str):
    """Safely call a plugin method, catching and logging exceptions."""
    try:
        yield
    except Exception as e:
        log.error("Error in '{}.{}': {}", plugin.data_source, method_name, e)
        log.debug("Exception details:", exc_info=True)


def _yield_from_plugins(
    func: Callable[..., Iterable[Ret]],
) -> Callable[..., Iterator[Ret]]:
    method_name = func.__name__

    @wraps(func)
    def wrapper(*args, **kwargs) -> Iterator[Ret]:
        for plugin in find_metadata_source_plugins():
            method = getattr(plugin, method_name)
            with handle_plugin_error(plugin, method_name):
                yield from filter(None, method(*args, **kwargs))

    return wrapper


@notify_info_yielded("albuminfo_received")
@_yield_from_plugins
def candidates(*args, **kwargs) -> Iterator[AlbumInfo]:
    yield from ()


@notify_info_yielded("trackinfo_received")
@_yield_from_plugins
def item_candidates(*args, **kwargs) -> Iterator[TrackInfo]:
    yield from ()


@notify_info_yielded("albuminfo_received")
@_yield_from_plugins
def albums_for_ids(*args, **kwargs) -> Iterator[AlbumInfo]:
    yield from ()


@notify_info_yielded("trackinfo_received")
@_yield_from_plugins
def tracks_for_ids(*args, **kwargs) -> Iterator[TrackInfo]:
    yield from ()


def album_for_id(_id: str) -> AlbumInfo | None:
    return next(albums_for_ids([_id]), None)


def track_for_id(_id: str) -> TrackInfo | None:
    return next(tracks_for_ids([_id]), None)

@semohr

semohr commented Jan 9, 2026

Copy link
Copy Markdown
Contributor Author

Looks reasonable 👍 Some details ofc missing but we should be able to adapt that.

@snejus
snejus force-pushed the error_handling_metadata_plugins branch 3 times, most recently from 0aae80e to cece9b2 Compare January 30, 2026 22:40
@snejus
snejus force-pushed the error_handling_metadata_plugins branch from cece9b2 to 2196bd8 Compare January 31, 2026 23:42
@snejus

snejus commented Jan 31, 2026

Copy link
Copy Markdown
Member

Going to merge this in as it is now since I'm keen to have this in the new release. We can revise the details a bit later if needed!

@snejus
snejus enabled auto-merge January 31, 2026 23:43
@snejus
snejus merged commit f9cf157 into master Jan 31, 2026
18 checks passed
@snejus
snejus deleted the error_handling_metadata_plugins branch January 31, 2026 23:47
@semohr

semohr commented Feb 1, 2026

Copy link
Copy Markdown
Contributor Author

Just seen the change.
I think coupling the names of the functions that tightly with the implementation is pretty convoluted. I get that it minimizes code but it is not really understandable in the long run and I don't think mypy can check this properly. Performance should also be worse compared to calling the functions directly.

We should find another way here imo.

aidenscott2016 added a commit to aidenscott2016/nixos that referenced this pull request May 2, 2026
Beets 2.5.1 crashes the entire import when MusicBrainz returns a
transient network error (connection reset). This was fixed in 2.6.0
(beetbox/beets#5965). Nixpkgs-unstable has 2.6.2, so build the
full beets stack from unstable python3.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core Pull requests that modify the beets core `beets`

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants