ML-21: Classify API errors as transient vs permanent

This commit is contained in:
Claudio Ortolina
2026-04-24 13:55:00 +01:00
parent be4f4686f8
commit a1c665b490
43 changed files with 1429 additions and 135 deletions
+23
View File
@@ -0,0 +1,23 @@
defmodule MusicLibrary.ErrorResponse do
@moduledoc """
Behaviour shared by all per-API `ErrorResponse` modules.
Each API (MusicBrainz, Discogs, Wikipedia, Brave Search, OpenAI, Last.fm)
returns a struct implementing this behaviour on HTTP failure, so
`MusicLibrary.Worker.ErrorHandler.to_oban_result/1` can dispatch uniformly
to produce the right Oban tuple (`{:snooze, n}` / `{:cancel, reason}`)
without needing to know which API raised the error.
"""
@doc """
Returns `true` if the error is transient and the worker should retry after
a delay; `false` if the error is permanent and the worker should cancel.
"""
@callback retryable?(struct()) :: boolean()
@doc """
Returns the snooze delay in seconds for retryable errors. Implementations
may return any positive integer; common defaults are 3060 s.
"""
@callback retry_delay_seconds(struct()) :: pos_integer()
end
+37
View File
@@ -0,0 +1,37 @@
defmodule MusicLibrary.HttpError do
@moduledoc """
Default HTTP status → error kind mapping shared by per-API `ErrorResponse` modules.
Each API's `ErrorResponse.from_response/1` uses this as a baseline before applying
API-specific overrides (e.g. MusicBrainz treats 503 as a rate limit, not a server
error; OpenAI splits HTTP 429 into `:rate_limit` vs `:auth_error` based on the
body `code`).
## Kinds
* `:rate_limit` — back off and retry (transient)
* `:server_error` — retry with backoff (transient)
* `:timeout` — retry with shorter backoff (transient)
* `:auth_error` — permanent until credentials change
* `:not_found` — permanent
* `:client_error` — permanent (malformed request)
* `:unknown` — unclassified; treated as permanent
"""
@type kind ::
:rate_limit
| :server_error
| :timeout
| :auth_error
| :not_found
| :client_error
| :unknown
@spec default_kind(integer()) :: kind()
def default_kind(429), do: :rate_limit
def default_kind(status) when status in 500..599, do: :server_error
def default_kind(status) when status in [401, 403], do: :auth_error
def default_kind(404), do: :not_found
def default_kind(status) when status in 400..499, do: :client_error
def default_kind(_), do: :unknown
end
@@ -1,8 +1,14 @@
defmodule MusicLibrary.Worker.ArtistRefreshDiscogsData do
@moduledoc false
use Oban.Worker, queue: :discogs, max_attempts: 3
alias MusicLibrary.Worker.ErrorHandler
@impl Oban.Worker
def perform(%Oban.Job{args: %{"id" => artist_info_id}}) do
MusicLibrary.Artists.refresh_discogs_data(artist_info_id)
artist_info_id
|> MusicLibrary.Artists.refresh_discogs_data()
|> ErrorHandler.to_oban_result()
end
end
@@ -1,8 +1,14 @@
defmodule MusicLibrary.Worker.ArtistRefreshMusicBrainzData do
@moduledoc false
use Oban.Worker, queue: :music_brainz, max_attempts: 3
alias MusicLibrary.Worker.ErrorHandler
@impl Oban.Worker
def perform(%Oban.Job{args: %{"id" => artist_info_id}}) do
MusicLibrary.Artists.refresh_musicbrainz_data(artist_info_id)
artist_info_id
|> MusicLibrary.Artists.refresh_musicbrainz_data()
|> ErrorHandler.to_oban_result()
end
end
@@ -1,11 +1,15 @@
defmodule MusicLibrary.Worker.ArtistRefreshWikipediaData do
@moduledoc false
use Oban.Worker, queue: :wikipedia, max_attempts: 3
alias MusicLibrary.Worker.ErrorHandler
@impl Oban.Worker
def perform(%Oban.Job{args: %{"id" => artist_info_id}}) do
with {:error, :no_english_wikipedia} <-
MusicLibrary.Artists.refresh_wikipedia_data(artist_info_id) do
{:cancel, :no_english_wikipedia}
case MusicLibrary.Artists.refresh_wikipedia_data(artist_info_id) do
{:error, :no_english_wikipedia} -> {:cancel, :no_english_wikipedia}
other -> ErrorHandler.to_oban_result(other)
end
end
end
+49
View File
@@ -0,0 +1,49 @@
defmodule MusicLibrary.Worker.ErrorHandler do
@moduledoc """
Converts context-layer results into Oban worker return values.
Workers call external APIs that return `{:error, %ErrorResponse{}}` (per-API
structs) on HTTP failures. This helper recognises any of the known error
response structs and translates them into the correct Oban tuple:
* retryable errors → `{:snooze, seconds}` so the attempt isn't consumed
* non-retryable errors → `{:cancel, reason}` so Oban stops retrying
* unknown `{:error, reason}` → passed through for Oban's default backoff
Workers that have app-layer atom-cancel reasons (e.g. `:no_english_wikipedia`,
`:cover_not_available`) must match those **before** calling this helper, since
atoms fall through to the generic `{:error, reason}` branch here.
"""
@error_structs [
LastFm.API.ErrorResponse,
MusicBrainz.API.ErrorResponse,
Discogs.API.ErrorResponse,
Wikipedia.API.ErrorResponse,
BraveSearch.API.ErrorResponse,
OpenAI.API.ErrorResponse
]
@type oban_result ::
:ok
| {:ok, term()}
| {:error, term()}
| {:cancel, term()}
| {:snooze, pos_integer()}
@spec to_oban_result(term()) :: oban_result()
def to_oban_result(:ok), do: :ok
def to_oban_result({:ok, _} = result), do: result
def to_oban_result({:error, %mod{} = response}) when mod in @error_structs do
if mod.retryable?(response) do
{:snooze, mod.retry_delay_seconds(response)}
else
{:cancel, response}
end
end
def to_oban_result({:error, reason}), do: {:error, reason}
def to_oban_result({:cancel, _} = result), do: result
def to_oban_result({:snooze, _} = result), do: result
end
+8 -11
View File
@@ -1,20 +1,17 @@
defmodule MusicLibrary.Worker.FetchArtistImage do
@moduledoc false
use Oban.Worker, queue: :heavy_writes, max_attempts: 3
alias MusicLibrary.Worker.ErrorHandler
@impl Oban.Worker
def perform(%Oban.Job{args: %{"id" => artist_id}}) do
case MusicLibrary.Artists.refresh_image(artist_id) do
{:ok, _artist_info} ->
:ok
{:error, :image_not_found} ->
{:cancel, :image_not_found}
{:error, :no_discogs_data} ->
{:cancel, :no_discogs_data}
error ->
error
{:ok, _artist_info} -> :ok
{:error, :image_not_found} -> {:cancel, :image_not_found}
{:error, :no_discogs_data} -> {:cancel, :no_discogs_data}
other -> ErrorHandler.to_oban_result(other)
end
end
end
@@ -1,8 +1,11 @@
defmodule MusicLibrary.Worker.FetchArtistInfo do
@moduledoc false
use Oban.Worker, queue: :default, max_attempts: 3
alias MusicLibrary.Artists
alias MusicLibrary.Records.Similarity
alias MusicLibrary.Worker.ErrorHandler
@impl Oban.Worker
def perform(%Oban.Job{args: %{"id" => artist_id}}) do
@@ -13,7 +16,7 @@ defmodule MusicLibrary.Worker.FetchArtistInfo do
Similarity.regenerate_artist_embeddings(artist_id)
else
{:error, :no_english_wikipedia} -> {:cancel, :no_english_wikipedia}
error -> error
other -> ErrorHandler.to_oban_result(other)
end
end
end
@@ -1,11 +1,15 @@
defmodule MusicLibrary.Worker.FetchArtistLastFmData do
@moduledoc false
use Oban.Worker, queue: :last_fm, max_attempts: 3
alias MusicLibrary.Worker.ErrorHandler
@impl Oban.Worker
def perform(%Oban.Job{args: %{"id" => artist_id}}) do
case MusicLibrary.Artists.refresh_lastfm_data(artist_id) do
{:ok, _artist_info} -> :ok
error -> error
other -> ErrorHandler.to_oban_result(other)
end
end
end
@@ -1,8 +1,11 @@
defmodule MusicLibrary.Worker.GenerateRecordEmbedding do
@moduledoc false
use Oban.Worker, queue: :heavy_writes, max_attempts: 3
alias MusicLibrary.Records
alias MusicLibrary.Records.Similarity
alias MusicLibrary.Worker.ErrorHandler
@impl Oban.Worker
def perform(%Oban.Job{args: %{"record_id" => record_id}}) do
@@ -11,7 +14,7 @@ defmodule MusicLibrary.Worker.GenerateRecordEmbedding do
case Similarity.generate_embedding(record) do
:noop -> :ok
{:ok, _} -> Records.notify_update(record)
{:error, _} = error -> error
other -> ErrorHandler.to_oban_result(other)
end
end
end
@@ -8,6 +8,7 @@ defmodule MusicLibrary.Worker.ImportFromMusicbrainzRelease do
use Oban.Worker, queue: :music_brainz, max_attempts: 3
alias MusicLibrary.Records.Record
alias MusicLibrary.Worker.ErrorHandler
@impl Oban.Worker
def perform(%Oban.Job{args: %{"release_id" => release_id} = args}) do
@@ -19,7 +20,7 @@ defmodule MusicLibrary.Worker.ImportFromMusicbrainzRelease do
case MusicLibrary.Records.import_from_musicbrainz_release(release_id, opts) do
{:ok, _record} -> :ok
{:error, reason} -> {:error, reason}
other -> ErrorHandler.to_oban_result(other)
end
end
end
@@ -13,6 +13,7 @@ defmodule MusicLibrary.Worker.ImportFromMusicbrainzReleaseGroup do
alias MusicLibrary.Records
alias MusicLibrary.Records.Record
alias MusicLibrary.Worker.ErrorHandler
@impl Oban.Worker
def perform(%Oban.Job{args: %{"release_group_id" => release_group_id} = args}) do
@@ -23,7 +24,7 @@ defmodule MusicLibrary.Worker.ImportFromMusicbrainzReleaseGroup do
case Records.import_from_musicbrainz_release_group(release_group_id, opts) do
{:ok, _record} -> :ok
{:error, reason} -> {:error, reason}
other -> ErrorHandler.to_oban_result(other)
end
end
end
+6 -2
View File
@@ -1,16 +1,20 @@
defmodule MusicLibrary.Worker.PopulateGenres do
@moduledoc false
use Oban.Worker, queue: :heavy_writes, max_attempts: 10
alias MusicLibrary.Records
alias MusicLibrary.Worker.ErrorHandler
@impl Oban.Worker
def perform(%Oban.Job{args: %{"id" => record_id}}) do
record = Records.get_record!(record_id)
with {:ok, updated_record} <- Records.populate_genres(record),
{:ok, _worker} <-
Records.Similarity.generate_embedding_async(updated_record) do
{:ok, _worker} <- Records.Similarity.generate_embedding_async(updated_record) do
Records.notify_update(updated_record)
else
other -> ErrorHandler.to_oban_result(other)
end
end
end
@@ -1,14 +1,18 @@
defmodule MusicLibrary.Worker.RecordRefreshMusicBrainzData do
@moduledoc false
use Oban.Worker, queue: :music_brainz, max_attempts: 3
alias MusicLibrary.Records
alias MusicLibrary.Worker.ErrorHandler
@impl Oban.Worker
def perform(%Oban.Job{args: %{"id" => record_id}}) do
record = Records.get_record!(record_id)
with {:ok, updated_record} <- Records.refresh_musicbrainz_data(record) do
Records.notify_update(updated_record)
case Records.refresh_musicbrainz_data(record) do
{:ok, updated_record} -> Records.notify_update(updated_record)
other -> ErrorHandler.to_oban_result(other)
end
end
end
+6 -8
View File
@@ -1,21 +1,19 @@
defmodule MusicLibrary.Worker.RefreshCover do
@moduledoc false
use Oban.Worker, queue: :heavy_writes, max_attempts: 3
alias MusicLibrary.Records
alias MusicLibrary.Worker.ErrorHandler
@impl Oban.Worker
def perform(%Oban.Job{args: %{"id" => record_id}}) do
record = Records.get_record!(record_id)
case Records.refresh_cover(record) do
{:ok, updated_record} ->
Records.notify_update(updated_record)
{:error, :cover_not_available} ->
{:cancel, :cover_not_available}
error ->
error
{:ok, updated_record} -> Records.notify_update(updated_record)
{:error, :cover_not_available} -> {:cancel, :cover_not_available}
other -> ErrorHandler.to_oban_result(other)
end
end
end