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
+24 -6
View File
@@ -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
+60
View File
@@ -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