ML-21: Classify API errors as transient vs permanent
This commit is contained in:
@@ -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 30–60 s.
|
||||
"""
|
||||
@callback retry_delay_seconds(struct()) :: pos_integer()
|
||||
end
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user