ML-21: Classify API errors as transient vs permanent
This commit is contained in:
+24
-6
@@ -3,12 +3,13 @@ defmodule BraveSearch.API do
|
||||
Interface to the Brave Search API.
|
||||
"""
|
||||
|
||||
alias BraveSearch.API.ErrorResponse
|
||||
alias Req.Request
|
||||
|
||||
require Logger
|
||||
|
||||
@spec search_images(String.t(), keyword(), BraveSearch.Config.t()) ::
|
||||
{:ok, [map()]} | {:error, term()}
|
||||
{:ok, [map()]} | {:error, ErrorResponse.t() | Exception.t()}
|
||||
def search_images(query, opts, config) do
|
||||
params = [q: query, count: Keyword.get(opts, :count, 20)]
|
||||
|
||||
@@ -61,16 +62,20 @@ defmodule BraveSearch.API do
|
||||
|> Request.merge_options(config.req_options)
|
||||
|> Req.RateLimiter.attach(name: :brave_search, cooldown: config.api_cooldown)
|
||||
|> Request.append_request_steps(log_attempt: &log_attempt/1)
|
||||
|> Request.append_response_steps(log_error: &log_error/1)
|
||||
|> Request.append_response_steps(parse_error: &parse_error/1)
|
||||
end
|
||||
|
||||
defp get_request(request) do
|
||||
case Req.get(request) do
|
||||
{:ok, response} when response.status == 200 ->
|
||||
{:ok, response.body}
|
||||
{:ok, %{status: status, body: body}} when status in 200..299 ->
|
||||
{:ok, body}
|
||||
|
||||
{:ok, response} ->
|
||||
{:error, response.body}
|
||||
{:ok, %{body: %ErrorResponse{} = error}} ->
|
||||
{:error, error}
|
||||
|
||||
# Image download path does not attach parse_error; fall back to raw body.
|
||||
{:ok, %{body: body}} ->
|
||||
{:error, body}
|
||||
|
||||
error ->
|
||||
error
|
||||
@@ -93,4 +98,17 @@ defmodule BraveSearch.API do
|
||||
|
||||
{request, response}
|
||||
end
|
||||
|
||||
defp parse_error({request, %{status: status} = response}) when status not in 200..299 do
|
||||
error = ErrorResponse.from_response(response)
|
||||
|
||||
Logger.error(fn ->
|
||||
url = URI.to_string(request.url)
|
||||
"Failed to fetch data from #{url}, status: #{status}, reason: #{inspect(response.body)}"
|
||||
end)
|
||||
|
||||
Request.halt(request, %{response | body: error})
|
||||
end
|
||||
|
||||
defp parse_error(tuple), do: tuple
|
||||
end
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
defmodule BraveSearch.API.ErrorResponse do
|
||||
@moduledoc """
|
||||
Structured error response for Brave Search API calls.
|
||||
|
||||
Brave Search returns a consistent JSON envelope on errors:
|
||||
|
||||
%{"type" => "ErrorResponse",
|
||||
"error" => %{"status" => 422, "code" => "SUBSCRIPTION_TOKEN_INVALID",
|
||||
"detail" => "...", "meta" => %{...}}}
|
||||
|
||||
HTTP status codes are the primary classifier (429 for rate limit, 5xx for
|
||||
server errors). Brave uses 422 for most validation failures including
|
||||
authentication errors that other APIs would return as 401.
|
||||
"""
|
||||
|
||||
@behaviour MusicLibrary.ErrorResponse
|
||||
|
||||
alias MusicLibrary.HttpError
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
status: integer() | nil,
|
||||
code: String.t() | nil,
|
||||
message: String.t() | nil,
|
||||
kind: HttpError.kind(),
|
||||
body: term()
|
||||
}
|
||||
|
||||
defstruct [:status, :code, :message, :kind, :body]
|
||||
|
||||
@spec from_response(Req.Response.t() | map()) :: t()
|
||||
def from_response(%{status: status, body: body} = _response) do
|
||||
%__MODULE__{
|
||||
status: status,
|
||||
code: extract_code(body),
|
||||
message: extract_message(body),
|
||||
kind: HttpError.default_kind(status),
|
||||
body: body
|
||||
}
|
||||
end
|
||||
|
||||
@impl MusicLibrary.ErrorResponse
|
||||
@spec retryable?(t()) :: boolean()
|
||||
def retryable?(%__MODULE__{kind: kind}) when kind in [:rate_limit, :server_error, :timeout],
|
||||
do: true
|
||||
|
||||
def retryable?(%__MODULE__{}), do: false
|
||||
|
||||
@impl MusicLibrary.ErrorResponse
|
||||
@spec retry_delay_seconds(t()) :: pos_integer()
|
||||
def retry_delay_seconds(%__MODULE__{kind: :rate_limit}), do: 60
|
||||
def retry_delay_seconds(%__MODULE__{kind: :server_error}), do: 30
|
||||
def retry_delay_seconds(%__MODULE__{kind: :timeout}), do: 10
|
||||
def retry_delay_seconds(%__MODULE__{}), do: 30
|
||||
|
||||
defp extract_code(%{"error" => %{"code" => code}}) when is_binary(code), do: code
|
||||
defp extract_code(_), do: nil
|
||||
|
||||
defp extract_message(%{"error" => %{"detail" => msg}}) when is_binary(msg), do: msg
|
||||
defp extract_message(_), do: nil
|
||||
end
|
||||
+25
-7
@@ -3,11 +3,13 @@ defmodule Discogs.API do
|
||||
Interface to the Discogs API.
|
||||
"""
|
||||
|
||||
alias Discogs.API.ErrorResponse
|
||||
alias Req.Request
|
||||
|
||||
require Logger
|
||||
|
||||
@spec get_artist(integer() | String.t(), Discogs.Config.t()) :: {:ok, map()} | {:error, term()}
|
||||
@spec get_artist(integer() | String.t(), Discogs.Config.t()) ::
|
||||
{:ok, map()} | {:error, ErrorResponse.t() | Exception.t()}
|
||||
def get_artist(id, config) do
|
||||
config
|
||||
|> new_request()
|
||||
@@ -41,17 +43,20 @@ defmodule Discogs.API do
|
||||
|> Request.merge_options(config.req_options)
|
||||
|> Req.RateLimiter.attach(name: :discogs, cooldown: config.api_cooldown)
|
||||
|> Request.append_request_steps(log_attempt: &log_attempt/1)
|
||||
|> Request.append_response_steps(log_error: &log_error/1)
|
||||
|> Request.append_response_steps(parse_error: &parse_error/1)
|
||||
end
|
||||
|
||||
defp get_request(request) do
|
||||
case Req.get(request) do
|
||||
{:ok, response} when response.status == 200 ->
|
||||
{:ok, response.body}
|
||||
{:ok, %{status: status, body: body}} when status in 200..299 ->
|
||||
{:ok, body}
|
||||
|
||||
# all non-success responses can be treated as errors
|
||||
{:ok, response} ->
|
||||
{:error, response.body}
|
||||
{:ok, %{body: %ErrorResponse{} = error}} ->
|
||||
{:error, error}
|
||||
|
||||
# Image download path does not attach parse_error; fall back to raw body.
|
||||
{:ok, %{body: body}} ->
|
||||
{:error, body}
|
||||
|
||||
error ->
|
||||
error
|
||||
@@ -74,4 +79,17 @@ defmodule Discogs.API do
|
||||
|
||||
{request, response}
|
||||
end
|
||||
|
||||
defp parse_error({request, %{status: status} = response}) when status not in 200..299 do
|
||||
error = ErrorResponse.from_response(response)
|
||||
|
||||
Logger.error(fn ->
|
||||
url = URI.to_string(request.url)
|
||||
"Failed to fetch data from #{url}, status: #{status}, reason: #{inspect(response.body)}"
|
||||
end)
|
||||
|
||||
Request.halt(request, %{response | body: error})
|
||||
end
|
||||
|
||||
defp parse_error(tuple), do: tuple
|
||||
end
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
defmodule Discogs.API.ErrorResponse do
|
||||
@moduledoc """
|
||||
Structured error response for Discogs API calls.
|
||||
|
||||
Discogs uses HTTP status codes as the error channel. Error bodies are JSON with
|
||||
a single `"message"` field and no application-level error codes.
|
||||
"""
|
||||
|
||||
@behaviour MusicLibrary.ErrorResponse
|
||||
|
||||
alias MusicLibrary.HttpError
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
status: integer() | nil,
|
||||
message: String.t() | nil,
|
||||
kind: HttpError.kind(),
|
||||
body: term()
|
||||
}
|
||||
|
||||
defstruct [:status, :message, :kind, :body]
|
||||
|
||||
@spec from_response(Req.Response.t() | map()) :: t()
|
||||
def from_response(%{status: status, body: body} = _response) do
|
||||
%__MODULE__{
|
||||
status: status,
|
||||
message: extract_message(body),
|
||||
kind: HttpError.default_kind(status),
|
||||
body: body
|
||||
}
|
||||
end
|
||||
|
||||
@impl MusicLibrary.ErrorResponse
|
||||
@spec retryable?(t()) :: boolean()
|
||||
def retryable?(%__MODULE__{kind: kind}) when kind in [:rate_limit, :server_error, :timeout],
|
||||
do: true
|
||||
|
||||
def retryable?(%__MODULE__{}), do: false
|
||||
|
||||
@impl MusicLibrary.ErrorResponse
|
||||
@spec retry_delay_seconds(t()) :: pos_integer()
|
||||
def retry_delay_seconds(%__MODULE__{kind: :rate_limit}), do: 60
|
||||
def retry_delay_seconds(%__MODULE__{kind: :server_error}), do: 30
|
||||
def retry_delay_seconds(%__MODULE__{kind: :timeout}), do: 10
|
||||
def retry_delay_seconds(%__MODULE__{}), do: 30
|
||||
|
||||
defp extract_message(%{"message" => message}) when is_binary(message), do: message
|
||||
defp extract_message(_), do: nil
|
||||
end
|
||||
@@ -1,4 +1,19 @@
|
||||
defmodule LastFm.API.ErrorResponse do
|
||||
@moduledoc """
|
||||
Structured error response for Last.fm API calls.
|
||||
|
||||
Last.fm is unusual among the APIs this project consumes: it returns HTTP 200
|
||||
with a `%{"error" => N, "message" => "..."}` JSON body on failure, carrying a
|
||||
numeric application-level error code in the range 2–29. Those codes map to
|
||||
the atoms declared in `t:error_atom/0`.
|
||||
|
||||
The struct-based helpers (`retryable?/1`, `retry_delay_seconds/1`) exist to
|
||||
share a uniform protocol with the per-HTTP-status `ErrorResponse` modules
|
||||
used by the other APIs — see `MusicLibrary.Worker.ErrorHandler`.
|
||||
"""
|
||||
|
||||
@behaviour MusicLibrary.ErrorResponse
|
||||
|
||||
defstruct [:error, :message]
|
||||
|
||||
@type error_atom ::
|
||||
@@ -66,4 +81,28 @@ defmodule LastFm.API.ErrorResponse do
|
||||
# 5 seconds
|
||||
def retry_delay(:operation_failed), do: 5_000
|
||||
def retry_delay(_), do: nil
|
||||
|
||||
@doc """
|
||||
Struct-based retryability predicate shared with other API `ErrorResponse` modules.
|
||||
|
||||
Enables `MusicLibrary.Worker.ErrorHandler.to_oban_result/1` to treat Last.fm
|
||||
uniformly alongside HTTP-status-based errors without requiring callers to
|
||||
unwrap the atom first.
|
||||
"""
|
||||
@impl MusicLibrary.ErrorResponse
|
||||
@spec retryable?(t()) :: boolean()
|
||||
def retryable?(%__MODULE__{error: error}), do: retryable_error?(error)
|
||||
|
||||
@doc """
|
||||
Struct-based retry delay in seconds. Falls back to 30 s when the underlying
|
||||
atom has no specific delay (see `retry_delay/1`).
|
||||
"""
|
||||
@impl MusicLibrary.ErrorResponse
|
||||
@spec retry_delay_seconds(t()) :: pos_integer()
|
||||
def retry_delay_seconds(%__MODULE__{error: error}) do
|
||||
case retry_delay(error) do
|
||||
ms when is_integer(ms) -> div(ms, 1000)
|
||||
nil -> 30
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
+34
-11
@@ -6,6 +6,7 @@ defmodule MusicBrainz.API do
|
||||
- Extend the metadata associated with existing records
|
||||
"""
|
||||
|
||||
alias MusicBrainz.API.ErrorResponse
|
||||
alias MusicBrainz.{Artist, ReleaseGroupSearchResult, ReleaseSearchResult}
|
||||
alias Req.Request
|
||||
|
||||
@@ -207,7 +208,8 @@ defmodule MusicBrainz.API do
|
||||
}
|
||||
"""
|
||||
|
||||
@spec get_release_group(String.t(), MusicBrainz.Config.t()) :: {:ok, map()} | {:error, term()}
|
||||
@spec get_release_group(String.t(), MusicBrainz.Config.t()) ::
|
||||
{:ok, map()} | {:error, ErrorResponse.t() | Exception.t()}
|
||||
def get_release_group(id, config) do
|
||||
config
|
||||
|> new_request()
|
||||
@@ -284,7 +286,8 @@ defmodule MusicBrainz.API do
|
||||
"title": "Clark (Soundtrack From the Netflix Series)"
|
||||
}
|
||||
"""
|
||||
@spec get_release(String.t(), MusicBrainz.Config.t()) :: {:ok, map()} | {:error, term()}
|
||||
@spec get_release(String.t(), MusicBrainz.Config.t()) ::
|
||||
{:ok, map()} | {:error, ErrorResponse.t() | Exception.t()}
|
||||
def get_release(id, config) do
|
||||
config
|
||||
|> new_request()
|
||||
@@ -299,7 +302,7 @@ defmodule MusicBrainz.API do
|
||||
end
|
||||
|
||||
@spec get_releases(String.t(), keyword(), MusicBrainz.Config.t()) ::
|
||||
{:ok, map()} | {:error, term()}
|
||||
{:ok, map()} | {:error, ErrorResponse.t() | Exception.t()}
|
||||
def get_releases(release_group_id, opts, config) do
|
||||
Keyword.validate!(opts, [:limit, :offset])
|
||||
|
||||
@@ -320,7 +323,7 @@ defmodule MusicBrainz.API do
|
||||
end
|
||||
|
||||
@spec search_release_by_barcode(String.t(), MusicBrainz.Config.t()) ::
|
||||
{:ok, [ReleaseSearchResult.t()]} | {:error, term()}
|
||||
{:ok, [ReleaseSearchResult.t()]} | {:error, ErrorResponse.t() | Exception.t()}
|
||||
def search_release_by_barcode(barcode, config) do
|
||||
config
|
||||
|> new_request()
|
||||
@@ -439,7 +442,7 @@ defmodule MusicBrainz.API do
|
||||
"""
|
||||
@spec search_release_group(String.t(), keyword(), MusicBrainz.Config.t()) ::
|
||||
{:ok, %{total_count: non_neg_integer(), release_groups: [ReleaseGroupSearchResult.t()]}}
|
||||
| {:error, term()}
|
||||
| {:error, ErrorResponse.t() | Exception.t()}
|
||||
def search_release_group(query, opts, config) do
|
||||
Keyword.validate!(opts, [:limit, :offset])
|
||||
|
||||
@@ -461,7 +464,8 @@ defmodule MusicBrainz.API do
|
||||
|> get_request()
|
||||
end
|
||||
|
||||
@spec get_artist(String.t(), MusicBrainz.Config.t()) :: {:ok, Artist.t()} | {:error, term()}
|
||||
@spec get_artist(String.t(), MusicBrainz.Config.t()) ::
|
||||
{:ok, Artist.t()} | {:error, ErrorResponse.t() | Exception.t()}
|
||||
def get_artist(musicbrainz_id, config) do
|
||||
config
|
||||
|> new_request()
|
||||
@@ -507,22 +511,41 @@ defmodule MusicBrainz.API do
|
||||
|> Request.merge_options(config.req_options)
|
||||
|> Req.RateLimiter.attach(name: :music_brainz, cooldown: config.api_cooldown)
|
||||
|> Request.append_request_steps(log_attempt: &log_attempt/1)
|
||||
|> Request.append_response_steps(parse_error: &parse_error/1)
|
||||
end
|
||||
|
||||
defp get_request(request) do
|
||||
case Req.get(request) do
|
||||
{:ok, response} when response.status == 200 ->
|
||||
{:ok, response.body}
|
||||
{:ok, %{status: status, body: body}} when status in 200..299 ->
|
||||
{:ok, body}
|
||||
|
||||
# all non-success responses can be treated as errors
|
||||
{:ok, response} ->
|
||||
{:error, response.body}
|
||||
{:ok, %{body: %ErrorResponse{} = error}} ->
|
||||
{:error, error}
|
||||
|
||||
# Fallback for pipelines without parse_error (e.g. cover-art binary path)
|
||||
# or Req exhausting retries before the step runs. Callers like
|
||||
# get_cover_art/2 normalise this further.
|
||||
{:ok, %{body: body}} ->
|
||||
{:error, body}
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_error({request, %{status: status} = response}) when status not in 200..299 do
|
||||
error = ErrorResponse.from_response(response)
|
||||
|
||||
Logger.error(fn ->
|
||||
url = URI.to_string(request.url)
|
||||
"Failed to fetch data from #{url}, status: #{status}, reason: #{inspect(response.body)}"
|
||||
end)
|
||||
|
||||
Request.halt(request, %{response | body: error})
|
||||
end
|
||||
|
||||
defp parse_error(tuple), do: tuple
|
||||
|
||||
defp log_attempt(request) do
|
||||
url = URI.to_string(request.url)
|
||||
Logger.debug("Fetching data from #{url}")
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
defmodule MusicBrainz.API.ErrorResponse do
|
||||
@moduledoc """
|
||||
Structured error response for MusicBrainz API calls.
|
||||
|
||||
MusicBrainz uses classic HTTP status codes as the error channel. The body is a
|
||||
flat JSON `{"error": "message"}` with no numeric application codes.
|
||||
|
||||
## Rate limiting
|
||||
|
||||
MusicBrainz signals rate limiting with **HTTP 503**, not 429. The service does
|
||||
not use 429 at all — a 503 response with a `Retry-After` header is the rate
|
||||
limit signal. `from_response/1` therefore maps 503 to `:rate_limit` (not the
|
||||
generic `:server_error` classification from `MusicLibrary.HttpError`).
|
||||
"""
|
||||
|
||||
@behaviour MusicLibrary.ErrorResponse
|
||||
|
||||
alias MusicLibrary.HttpError
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
status: integer() | nil,
|
||||
message: String.t() | nil,
|
||||
kind: HttpError.kind(),
|
||||
body: term()
|
||||
}
|
||||
|
||||
defstruct [:status, :message, :kind, :body]
|
||||
|
||||
@spec from_response(Req.Response.t() | map()) :: t()
|
||||
def from_response(%{status: 503, body: body} = _response) do
|
||||
%__MODULE__{
|
||||
status: 503,
|
||||
message: extract_message(body),
|
||||
kind: :rate_limit,
|
||||
body: body
|
||||
}
|
||||
end
|
||||
|
||||
def from_response(%{status: status, body: body} = _response) do
|
||||
%__MODULE__{
|
||||
status: status,
|
||||
message: extract_message(body),
|
||||
kind: HttpError.default_kind(status),
|
||||
body: body
|
||||
}
|
||||
end
|
||||
|
||||
@impl MusicLibrary.ErrorResponse
|
||||
@spec retryable?(t()) :: boolean()
|
||||
def retryable?(%__MODULE__{kind: kind}) when kind in [:rate_limit, :server_error, :timeout],
|
||||
do: true
|
||||
|
||||
def retryable?(%__MODULE__{}), do: false
|
||||
|
||||
@impl MusicLibrary.ErrorResponse
|
||||
@spec retry_delay_seconds(t()) :: pos_integer()
|
||||
def retry_delay_seconds(%__MODULE__{kind: :rate_limit}), do: 60
|
||||
def retry_delay_seconds(%__MODULE__{kind: :server_error}), do: 30
|
||||
def retry_delay_seconds(%__MODULE__{kind: :timeout}), do: 10
|
||||
def retry_delay_seconds(%__MODULE__{}), do: 30
|
||||
|
||||
defp extract_message(%{"error" => message}) when is_binary(message), do: message
|
||||
defp extract_message(_), do: nil
|
||||
end
|
||||
@@ -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
|
||||
|
||||
@@ -399,7 +399,7 @@ defmodule MusicLibraryWeb.Components.Chat do
|
||||
)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Chat streaming error: #{reason}")
|
||||
Logger.error("Chat streaming error: #{inspect(reason)}")
|
||||
|
||||
LiveView.send_update(parent_pid, __MODULE__,
|
||||
id: component_id,
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ defmodule OpenAI do
|
||||
API.gpt(completion, config())
|
||||
end
|
||||
|
||||
@spec chat_stream([map()], chat_stream_opts()) :: :ok | {:error, String.t()}
|
||||
@spec chat_stream([map()], chat_stream_opts()) :: :ok | {:error, term()}
|
||||
def chat_stream(messages, opts \\ []) when is_list(messages) do
|
||||
model = Keyword.get(opts, :model, "gpt-4.1")
|
||||
temperature = Keyword.get(opts, :temperature, 0.7)
|
||||
|
||||
+37
-25
@@ -1,13 +1,23 @@
|
||||
defmodule OpenAI.API do
|
||||
@moduledoc """
|
||||
Low-level HTTP client for the OpenAI API (chat completions, streaming responses, embeddings).
|
||||
|
||||
HTTP errors are returned as `OpenAI.API.ErrorResponse` structs, which classify
|
||||
rate-limit failures (retryable) separately from billing-quota failures
|
||||
(`insufficient_quota` — non-retryable) despite both using HTTP 429.
|
||||
|
||||
Mid-stream failures in `chat_stream/6` come from parsed SSE event payloads
|
||||
rather than HTTP responses and remain as message strings passed through the
|
||||
stream callback plumbing.
|
||||
"""
|
||||
|
||||
alias OpenAI.API.ErrorResponse
|
||||
alias Req.Request
|
||||
|
||||
require Logger
|
||||
|
||||
@spec gpt(OpenAI.Completion.t(), OpenAI.Config.t()) :: {:ok, map()} | {:error, term()}
|
||||
@spec gpt(OpenAI.Completion.t(), OpenAI.Config.t()) ::
|
||||
{:ok, map()} | {:error, ErrorResponse.t() | Exception.t()}
|
||||
def gpt(completion, config) do
|
||||
case config
|
||||
|> new_request()
|
||||
@@ -27,17 +37,17 @@ defmodule OpenAI.API do
|
||||
content = get_in(body, ["choices", Access.at(0), "message", "content"])
|
||||
JSON.decode(content)
|
||||
|
||||
{:ok, %{body: body}} ->
|
||||
{:error, "OpenAI API error: #{inspect(body)}"}
|
||||
{:ok, response} ->
|
||||
{:error, ErrorResponse.from_response(response)}
|
||||
|
||||
{:error, exception} ->
|
||||
{:error, "Connection error: #{Exception.message(exception)}"}
|
||||
{:error, exception}
|
||||
end
|
||||
end
|
||||
|
||||
@spec chat_stream([map()], String.t(), String.t(), float(), OpenAI.Config.t(), (String.t() ->
|
||||
any())) ::
|
||||
:ok | {:error, String.t()}
|
||||
:ok | {:error, ErrorResponse.t() | Exception.t() | String.t()}
|
||||
def chat_stream(messages, instructions, model, temperature, config, cb) do
|
||||
config
|
||||
|> new_request()
|
||||
@@ -86,33 +96,35 @@ defmodule OpenAI.API do
|
||||
:ok
|
||||
end
|
||||
|
||||
{:ok, %{body: body}} ->
|
||||
{:error, "OpenAI API error: #{inspect(body)}"}
|
||||
{:ok, response} ->
|
||||
{:error, ErrorResponse.from_response(response)}
|
||||
|
||||
{:error, exception} ->
|
||||
{:error, "Connection error: #{Exception.message(exception)}"}
|
||||
{:error, exception}
|
||||
end
|
||||
end
|
||||
|
||||
@spec get_embeddings(String.t(), OpenAI.Config.t()) :: {:ok, [float()]} | {:error, term()}
|
||||
@spec get_embeddings(String.t(), OpenAI.Config.t()) ::
|
||||
{:ok, [float()]} | {:error, ErrorResponse.t() | Exception.t()}
|
||||
def get_embeddings(text, config) do
|
||||
resp =
|
||||
config
|
||||
|> new_request()
|
||||
|> Req.merge(
|
||||
url: "/v1/embeddings",
|
||||
json: %{
|
||||
input: text,
|
||||
model: "text-embedding-3-small"
|
||||
}
|
||||
)
|
||||
|> Req.post!()
|
||||
case config
|
||||
|> new_request()
|
||||
|> Req.merge(
|
||||
url: "/v1/embeddings",
|
||||
json: %{
|
||||
input: text,
|
||||
model: "text-embedding-3-small"
|
||||
}
|
||||
)
|
||||
|> Req.post() do
|
||||
{:ok, %{status: 200, body: body}} ->
|
||||
{:ok, get_in(body, ["data", Access.at(0), "embedding"])}
|
||||
|
||||
if resp.status == 200 do
|
||||
embeddings = get_in(resp.body, ["data", Access.at(0), "embedding"])
|
||||
{:ok, embeddings}
|
||||
else
|
||||
{:error, resp.body}
|
||||
{:ok, response} ->
|
||||
{:error, ErrorResponse.from_response(response)}
|
||||
|
||||
{:error, exception} ->
|
||||
{:error, exception}
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
defmodule OpenAI.API.ErrorResponse do
|
||||
@moduledoc """
|
||||
Structured error response for OpenAI API calls.
|
||||
|
||||
OpenAI uses HTTP status codes as the primary classifier, with one critical
|
||||
body-peek exception:
|
||||
|
||||
* HTTP **429** covers both `code: "rate_limit_exceeded"` (transient — retry)
|
||||
and `code: "insufficient_quota"` (permanent — billing failure, cancel).
|
||||
`from_response/1` reads `error.code` to disambiguate.
|
||||
|
||||
Error body shape (non-streaming):
|
||||
|
||||
%{"error" => %{"message" => ..., "type" => ..., "code" => ..., "param" => ...}}
|
||||
|
||||
Mid-stream SSE `error` / `response.failed` events are handled separately in
|
||||
`OpenAI.API.chat_stream/6` and do not flow through this module.
|
||||
"""
|
||||
|
||||
@behaviour MusicLibrary.ErrorResponse
|
||||
|
||||
alias MusicLibrary.HttpError
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
status: integer() | nil,
|
||||
code: String.t() | nil,
|
||||
type: String.t() | nil,
|
||||
message: String.t() | nil,
|
||||
kind: HttpError.kind(),
|
||||
body: term()
|
||||
}
|
||||
|
||||
defstruct [:status, :code, :type, :message, :kind, :body]
|
||||
|
||||
@spec from_response(Req.Response.t() | map()) :: t()
|
||||
def from_response(%{status: 429, body: %{"error" => %{"code" => "insufficient_quota"} = e}} = r) do
|
||||
%__MODULE__{
|
||||
status: 429,
|
||||
code: "insufficient_quota",
|
||||
type: e["type"],
|
||||
message: e["message"],
|
||||
kind: :auth_error,
|
||||
body: r.body
|
||||
}
|
||||
end
|
||||
|
||||
def from_response(%{status: status, body: %{"error" => err} = body} = _response)
|
||||
when is_map(err) do
|
||||
%__MODULE__{
|
||||
status: status,
|
||||
code: err["code"],
|
||||
type: err["type"],
|
||||
message: err["message"],
|
||||
kind: HttpError.default_kind(status),
|
||||
body: body
|
||||
}
|
||||
end
|
||||
|
||||
def from_response(%{status: status, body: body} = _response) do
|
||||
%__MODULE__{
|
||||
status: status,
|
||||
code: nil,
|
||||
type: nil,
|
||||
message: nil,
|
||||
kind: HttpError.default_kind(status),
|
||||
body: body
|
||||
}
|
||||
end
|
||||
|
||||
@impl MusicLibrary.ErrorResponse
|
||||
@spec retryable?(t()) :: boolean()
|
||||
def retryable?(%__MODULE__{kind: kind}) when kind in [:rate_limit, :server_error, :timeout],
|
||||
do: true
|
||||
|
||||
def retryable?(%__MODULE__{}), do: false
|
||||
|
||||
@impl MusicLibrary.ErrorResponse
|
||||
@spec retry_delay_seconds(t()) :: pos_integer()
|
||||
def retry_delay_seconds(%__MODULE__{kind: :rate_limit}), do: 60
|
||||
def retry_delay_seconds(%__MODULE__{kind: :server_error}), do: 30
|
||||
def retry_delay_seconds(%__MODULE__{kind: :timeout}), do: 10
|
||||
def retry_delay_seconds(%__MODULE__{}), do: 30
|
||||
end
|
||||
+50
-16
@@ -1,14 +1,23 @@
|
||||
defmodule Wikipedia.API do
|
||||
@moduledoc """
|
||||
Interface to the Wikidata and Wikipedia APIs.
|
||||
|
||||
Two API surfaces are used, with different error handling:
|
||||
|
||||
* **Action API** (`/w/api.php`) returns HTTP 200 with an `{"error": {...}}`
|
||||
payload on failure. `parse_error/1` promotes those bodies into
|
||||
`Wikipedia.API.ErrorResponse` errors.
|
||||
* **REST v1 API** (`/api/rest_v1/page/summary/:title`) uses classic HTTP
|
||||
status codes. `parse_error/1` handles non-2xx statuses the same way.
|
||||
"""
|
||||
|
||||
alias Req.Request
|
||||
alias Wikipedia.API.ErrorResponse
|
||||
|
||||
require Logger
|
||||
|
||||
@spec get_wikipedia_title(String.t(), Wikipedia.Config.t()) ::
|
||||
{:ok, String.t() | nil} | {:error, term()}
|
||||
{:ok, String.t() | nil} | {:error, ErrorResponse.t() | Exception.t()}
|
||||
def get_wikipedia_title(wikidata_id, config) do
|
||||
case config
|
||||
|> new_request("https://www.wikidata.org")
|
||||
@@ -32,7 +41,8 @@ defmodule Wikipedia.API do
|
||||
end
|
||||
end
|
||||
|
||||
@spec get_article_summary(String.t(), Wikipedia.Config.t()) :: {:ok, map()} | {:error, term()}
|
||||
@spec get_article_summary(String.t(), Wikipedia.Config.t()) ::
|
||||
{:ok, map()} | {:error, ErrorResponse.t() | Exception.t()}
|
||||
def get_article_summary(title, config) do
|
||||
config
|
||||
|> new_request("https://en.wikipedia.org")
|
||||
@@ -41,7 +51,7 @@ defmodule Wikipedia.API do
|
||||
end
|
||||
|
||||
@spec get_article_extract(String.t(), Wikipedia.Config.t()) ::
|
||||
{:ok, String.t() | nil} | {:error, term()}
|
||||
{:ok, String.t() | nil} | {:error, ErrorResponse.t() | Exception.t()}
|
||||
def get_article_extract(title, config) do
|
||||
case config
|
||||
|> new_request("https://en.wikipedia.org")
|
||||
@@ -80,16 +90,22 @@ defmodule Wikipedia.API do
|
||||
|> Request.merge_options(config.req_options)
|
||||
|> Req.RateLimiter.attach(name: :wikipedia, cooldown: config.api_cooldown)
|
||||
|> Request.append_request_steps(log_attempt: &log_attempt/1)
|
||||
|> Request.append_response_steps(log_error: &log_error/1)
|
||||
|> Request.append_response_steps(parse_error: &parse_error/1)
|
||||
end
|
||||
|
||||
defp get_request(request) do
|
||||
case Req.get(request) do
|
||||
{:ok, response} when response.status == 200 ->
|
||||
{:ok, response.body}
|
||||
{:ok, %{status: 200, body: %ErrorResponse{} = error}} ->
|
||||
{:error, error}
|
||||
|
||||
{:ok, response} ->
|
||||
{:error, response.body}
|
||||
{:ok, %{status: status, body: body}} when status in 200..299 ->
|
||||
{:ok, body}
|
||||
|
||||
{:ok, %{body: %ErrorResponse{} = error}} ->
|
||||
{:error, error}
|
||||
|
||||
{:ok, %{body: body}} ->
|
||||
{:error, body}
|
||||
|
||||
error ->
|
||||
error
|
||||
@@ -102,14 +118,32 @@ defmodule Wikipedia.API do
|
||||
request
|
||||
end
|
||||
|
||||
defp log_error({request, response}) do
|
||||
if response.status in 400..499 or response.status in 500..599 do
|
||||
Logger.error(fn ->
|
||||
url = URI.to_string(request.url)
|
||||
"Failed to fetch data from #{url}, reason: #{inspect(response.body)}"
|
||||
end)
|
||||
end
|
||||
# Action API: HTTP 200 with an error envelope in the body.
|
||||
defp parse_error(
|
||||
{request,
|
||||
%{status: 200, body: %{"error" => %{"code" => _, "info" => _}} = body} = response}
|
||||
) do
|
||||
error = ErrorResponse.from_action_api_body(body)
|
||||
|
||||
{request, response}
|
||||
Logger.error(fn ->
|
||||
url = URI.to_string(request.url)
|
||||
"Failed to fetch data from #{url}, code: #{error.code}, info: #{error.message}"
|
||||
end)
|
||||
|
||||
Request.halt(request, %{response | body: error})
|
||||
end
|
||||
|
||||
# REST v1 API and other non-2xx responses.
|
||||
defp parse_error({request, %{status: status} = response}) when status not in 200..299 do
|
||||
error = ErrorResponse.from_response(response)
|
||||
|
||||
Logger.error(fn ->
|
||||
url = URI.to_string(request.url)
|
||||
"Failed to fetch data from #{url}, status: #{status}, reason: #{inspect(response.body)}"
|
||||
end)
|
||||
|
||||
Request.halt(request, %{response | body: error})
|
||||
end
|
||||
|
||||
defp parse_error(tuple), do: tuple
|
||||
end
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
defmodule Wikipedia.API.ErrorResponse do
|
||||
@moduledoc """
|
||||
Structured error response for Wikipedia/Wikidata API calls.
|
||||
|
||||
Wikipedia exposes two API surfaces with different error paradigms:
|
||||
|
||||
* **Action API** (`/w/api.php`, used by `get_wikipedia_title/2` and
|
||||
`get_article_extract/2`) returns **HTTP 200 with an error in the body**:
|
||||
`%{"error" => %{"code" => ..., "info" => ...}}`. Without body inspection
|
||||
the error would be silently returned as `{:ok, body}`.
|
||||
|
||||
* **REST v1 API** (`/api/rest_v1/page/summary/:title`, used by
|
||||
`get_article_summary/2`) uses classic HTTP status codes.
|
||||
|
||||
`from_response/1` handles both paths. `from_action_api_body/1` is a dedicated
|
||||
entry point for the HTTP 200 + body-error case.
|
||||
|
||||
## Non-error body shapes
|
||||
|
||||
These are NOT errors and should continue to return `{:ok, body}`:
|
||||
|
||||
* `%{"entities" => %{_id => %{"missing" => ""}}}` from `wbgetentities` —
|
||||
the facade converts this to `{:error, :no_english_wikipedia}` at a
|
||||
higher level.
|
||||
* `get_in(body, ["query", "pages", _, "extract"])` returning `nil` — a
|
||||
title exists but has no extract.
|
||||
|
||||
The discriminator for an actual error is the literal top-level `"error"` key
|
||||
whose value is a map with both `"code"` and `"info"`.
|
||||
"""
|
||||
|
||||
@behaviour MusicLibrary.ErrorResponse
|
||||
|
||||
alias MusicLibrary.HttpError
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
status: integer() | nil,
|
||||
code: String.t() | nil,
|
||||
message: String.t() | nil,
|
||||
kind: HttpError.kind(),
|
||||
body: term()
|
||||
}
|
||||
|
||||
defstruct [:status, :code, :message, :kind, :body]
|
||||
|
||||
@spec from_response(Req.Response.t() | map()) :: t()
|
||||
def from_response(%{status: status, body: body} = _response) do
|
||||
%__MODULE__{
|
||||
status: status,
|
||||
code: extract_rest_code(body),
|
||||
message: extract_rest_message(body),
|
||||
kind: HttpError.default_kind(status),
|
||||
body: body
|
||||
}
|
||||
end
|
||||
|
||||
@spec from_action_api_body(map()) :: t()
|
||||
def from_action_api_body(%{"error" => %{"code" => code, "info" => info}} = body) do
|
||||
%__MODULE__{
|
||||
status: 200,
|
||||
code: code,
|
||||
message: info,
|
||||
kind: action_api_kind(code),
|
||||
body: body
|
||||
}
|
||||
end
|
||||
|
||||
@impl MusicLibrary.ErrorResponse
|
||||
@spec retryable?(t()) :: boolean()
|
||||
def retryable?(%__MODULE__{kind: kind}) when kind in [:rate_limit, :server_error, :timeout],
|
||||
do: true
|
||||
|
||||
def retryable?(%__MODULE__{}), do: false
|
||||
|
||||
@impl MusicLibrary.ErrorResponse
|
||||
@spec retry_delay_seconds(t()) :: pos_integer()
|
||||
def retry_delay_seconds(%__MODULE__{kind: :rate_limit}), do: 30
|
||||
def retry_delay_seconds(%__MODULE__{kind: :server_error}), do: 30
|
||||
def retry_delay_seconds(%__MODULE__{kind: :timeout}), do: 10
|
||||
def retry_delay_seconds(%__MODULE__{}), do: 30
|
||||
|
||||
defp action_api_kind("ratelimited"), do: :rate_limit
|
||||
defp action_api_kind("maxlag"), do: :rate_limit
|
||||
defp action_api_kind("readonly"), do: :server_error
|
||||
defp action_api_kind("internal_api_error_" <> _), do: :server_error
|
||||
defp action_api_kind(_), do: :client_error
|
||||
|
||||
defp extract_rest_code(%{"httpCode" => code}), do: to_string(code)
|
||||
defp extract_rest_code(_), do: nil
|
||||
|
||||
defp extract_rest_message(%{"messageTranslations" => %{"en" => msg}}), do: msg
|
||||
defp extract_rest_message(%{"detail" => msg}) when is_binary(msg), do: msg
|
||||
defp extract_rest_message(_), do: nil
|
||||
end
|
||||
Reference in New Issue
Block a user