ML-146: honour API retry headers
This commit is contained in:
@@ -16,25 +16,28 @@ defmodule BraveSearch.API.ErrorResponse do
|
||||
@behaviour MusicLibrary.ErrorResponse
|
||||
|
||||
alias MusicLibrary.HttpError
|
||||
alias MusicLibrary.RetryDelay
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
status: integer() | nil,
|
||||
code: String.t() | nil,
|
||||
message: String.t() | nil,
|
||||
kind: HttpError.kind(),
|
||||
body: term()
|
||||
body: term(),
|
||||
retry_delay_seconds: pos_integer() | nil
|
||||
}
|
||||
|
||||
defstruct [:status, :code, :message, :kind, :body]
|
||||
defstruct [:status, :code, :message, :kind, :body, :retry_delay_seconds]
|
||||
|
||||
@spec from_response(Req.Response.t() | map()) :: t()
|
||||
def from_response(%{status: status, body: body} = _response) do
|
||||
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
|
||||
body: body,
|
||||
retry_delay_seconds: RetryDelay.reset_seconds(response, "x-ratelimit-reset")
|
||||
}
|
||||
end
|
||||
|
||||
@@ -47,6 +50,9 @@ defmodule BraveSearch.API.ErrorResponse do
|
||||
|
||||
@impl MusicLibrary.ErrorResponse
|
||||
@spec retry_delay_seconds(t()) :: pos_integer()
|
||||
def retry_delay_seconds(%__MODULE__{retry_delay_seconds: seconds}) when is_integer(seconds),
|
||||
do: seconds
|
||||
|
||||
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
|
||||
|
||||
@@ -16,32 +16,36 @@ defmodule MusicBrainz.API.ErrorResponse do
|
||||
@behaviour MusicLibrary.ErrorResponse
|
||||
|
||||
alias MusicLibrary.HttpError
|
||||
alias MusicLibrary.RetryDelay
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
status: integer() | nil,
|
||||
message: String.t() | nil,
|
||||
kind: HttpError.kind(),
|
||||
body: term()
|
||||
body: term(),
|
||||
retry_delay_seconds: pos_integer() | nil
|
||||
}
|
||||
|
||||
defstruct [:status, :message, :kind, :body]
|
||||
defstruct [:status, :message, :kind, :body, :retry_delay_seconds]
|
||||
|
||||
@spec from_response(Req.Response.t() | map()) :: t()
|
||||
def from_response(%{status: 503, body: body} = _response) do
|
||||
def from_response(%{status: 503, body: body} = response) do
|
||||
%__MODULE__{
|
||||
status: 503,
|
||||
message: extract_message(body),
|
||||
kind: :rate_limit,
|
||||
body: body
|
||||
body: body,
|
||||
retry_delay_seconds: RetryDelay.retry_after_seconds(response)
|
||||
}
|
||||
end
|
||||
|
||||
def from_response(%{status: status, body: body} = _response) do
|
||||
def from_response(%{status: status, body: body} = response) do
|
||||
%__MODULE__{
|
||||
status: status,
|
||||
message: extract_message(body),
|
||||
kind: HttpError.default_kind(status),
|
||||
body: body
|
||||
body: body,
|
||||
retry_delay_seconds: RetryDelay.retry_after_seconds(response)
|
||||
}
|
||||
end
|
||||
|
||||
@@ -54,6 +58,9 @@ defmodule MusicBrainz.API.ErrorResponse do
|
||||
|
||||
@impl MusicLibrary.ErrorResponse
|
||||
@spec retry_delay_seconds(t()) :: pos_integer()
|
||||
def retry_delay_seconds(%__MODULE__{retry_delay_seconds: seconds}) when is_integer(seconds),
|
||||
do: seconds
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
defmodule MusicLibrary.RetryDelay do
|
||||
@moduledoc """
|
||||
Parses provider retry/reset headers into clamped snooze delays.
|
||||
|
||||
Header values come from upstream APIs and should not be trusted blindly. Parsed
|
||||
values are clamped to keep Oban snoozes useful without allowing pathological
|
||||
values to churn jobs or stall them for too long.
|
||||
"""
|
||||
|
||||
alias Req.Response
|
||||
|
||||
@min_seconds 5
|
||||
@max_seconds 300
|
||||
|
||||
@doc """
|
||||
Parses a `Retry-After` header that contains seconds.
|
||||
"""
|
||||
@spec retry_after_seconds(Response.t() | map()) :: pos_integer() | nil
|
||||
def retry_after_seconds(response), do: integer_header_seconds(response, "retry-after")
|
||||
|
||||
@doc """
|
||||
Parses a reset header containing one or more comma-separated second values.
|
||||
"""
|
||||
@spec reset_seconds(Response.t() | map(), String.t()) :: pos_integer() | nil
|
||||
def reset_seconds(response, header_name), do: integer_header_seconds(response, header_name)
|
||||
|
||||
@doc """
|
||||
Parses OpenAI request/token reset duration headers.
|
||||
"""
|
||||
@spec openai_reset_seconds(Response.t() | map()) :: pos_integer() | nil
|
||||
def openai_reset_seconds(response) do
|
||||
values =
|
||||
header_values(response, "retry-after") ++
|
||||
header_values(response, "x-ratelimit-reset-requests") ++
|
||||
header_values(response, "x-ratelimit-reset-tokens")
|
||||
|
||||
values
|
||||
|> Enum.flat_map(&String.split(&1, ","))
|
||||
|> Enum.map(&parse_openai_reset/1)
|
||||
|> max_clamped()
|
||||
end
|
||||
|
||||
defp integer_header_seconds(response, header_name) do
|
||||
response
|
||||
|> header_values(header_name)
|
||||
|> Enum.flat_map(&String.split(&1, ","))
|
||||
|> Enum.map(&parse_positive_integer/1)
|
||||
|> max_clamped()
|
||||
end
|
||||
|
||||
defp header_values(%{headers: _} = response, header_name) do
|
||||
Response.get_header(response, String.downcase(header_name))
|
||||
end
|
||||
|
||||
defp header_values(_response, _header_name), do: []
|
||||
|
||||
defp parse_positive_integer(value) do
|
||||
value = String.trim(value)
|
||||
|
||||
case Integer.parse(value) do
|
||||
{seconds, ""} when seconds >= 0 -> seconds
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_openai_reset(value) do
|
||||
case parse_positive_integer(value) do
|
||||
seconds when is_integer(seconds) -> seconds
|
||||
nil -> parse_duration(value)
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_duration(value) do
|
||||
value = String.trim(value)
|
||||
|
||||
case Regex.scan(~r/(\d+(?:\.\d+)?)(ms|s|m)/i, value) do
|
||||
[] -> nil
|
||||
parts -> parts_to_seconds(parts, value)
|
||||
end
|
||||
end
|
||||
|
||||
defp parts_to_seconds(parts, value) do
|
||||
parsed =
|
||||
Enum.map(parts, fn [_token, amount, unit] ->
|
||||
{parse_number(amount), String.downcase(unit)}
|
||||
end)
|
||||
|
||||
rebuilt =
|
||||
Enum.map_join(parts, fn [token, _amount, _unit] -> String.downcase(token) end)
|
||||
|
||||
normalized = value |> String.downcase() |> String.replace(~r/\s+/, "")
|
||||
|
||||
if rebuilt == normalized do
|
||||
total_duration_seconds(parsed)
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
defp total_duration_seconds(parts) do
|
||||
parts
|
||||
|> Enum.map(fn {amount, unit} -> duration_to_seconds(amount, unit) end)
|
||||
|> Enum.reduce_while(0, fn
|
||||
nil, _total -> {:halt, nil}
|
||||
seconds, total -> {:cont, total + seconds}
|
||||
end)
|
||||
end
|
||||
|
||||
defp parse_number(amount) do
|
||||
case Float.parse(amount) do
|
||||
{number, ""} -> number
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp duration_to_seconds(nil, _unit), do: nil
|
||||
defp duration_to_seconds(amount, _unit) when amount < 0, do: nil
|
||||
defp duration_to_seconds(amount, "ms"), do: ceil(amount / 1000)
|
||||
defp duration_to_seconds(amount, "s"), do: ceil(amount)
|
||||
defp duration_to_seconds(amount, "m"), do: ceil(amount * 60)
|
||||
|
||||
defp max_clamped(values) do
|
||||
values
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> Enum.max(fn -> nil end)
|
||||
|> clamp()
|
||||
end
|
||||
|
||||
defp clamp(nil), do: nil
|
||||
defp clamp(seconds) when seconds < @min_seconds, do: @min_seconds
|
||||
defp clamp(seconds) when seconds > @max_seconds, do: @max_seconds
|
||||
defp clamp(seconds), do: seconds
|
||||
end
|
||||
@@ -20,6 +20,7 @@ defmodule OpenAI.API.ErrorResponse do
|
||||
@behaviour MusicLibrary.ErrorResponse
|
||||
|
||||
alias MusicLibrary.HttpError
|
||||
alias MusicLibrary.RetryDelay
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
status: integer() | nil,
|
||||
@@ -27,10 +28,11 @@ defmodule OpenAI.API.ErrorResponse do
|
||||
type: String.t() | nil,
|
||||
message: String.t() | nil,
|
||||
kind: HttpError.kind(),
|
||||
body: term()
|
||||
body: term(),
|
||||
retry_delay_seconds: pos_integer() | nil
|
||||
}
|
||||
|
||||
defstruct [:status, :code, :type, :message, :kind, :body]
|
||||
defstruct [:status, :code, :type, :message, :kind, :body, :retry_delay_seconds]
|
||||
|
||||
@spec from_response(Req.Response.t() | map()) :: t()
|
||||
def from_response(%{status: 429, body: %{"error" => %{"code" => "insufficient_quota"} = e}} = r) do
|
||||
@@ -40,11 +42,12 @@ defmodule OpenAI.API.ErrorResponse do
|
||||
type: e["type"],
|
||||
message: e["message"],
|
||||
kind: :auth_error,
|
||||
body: r.body
|
||||
body: r.body,
|
||||
retry_delay_seconds: RetryDelay.openai_reset_seconds(r)
|
||||
}
|
||||
end
|
||||
|
||||
def from_response(%{status: status, body: %{"error" => err} = body} = _response)
|
||||
def from_response(%{status: status, body: %{"error" => err} = body} = response)
|
||||
when is_map(err) do
|
||||
%__MODULE__{
|
||||
status: status,
|
||||
@@ -52,18 +55,20 @@ defmodule OpenAI.API.ErrorResponse do
|
||||
type: err["type"],
|
||||
message: err["message"],
|
||||
kind: HttpError.default_kind(status),
|
||||
body: body
|
||||
body: body,
|
||||
retry_delay_seconds: RetryDelay.openai_reset_seconds(response)
|
||||
}
|
||||
end
|
||||
|
||||
def from_response(%{status: status, body: body} = _response) do
|
||||
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
|
||||
body: body,
|
||||
retry_delay_seconds: RetryDelay.openai_reset_seconds(response)
|
||||
}
|
||||
end
|
||||
|
||||
@@ -76,6 +81,9 @@ defmodule OpenAI.API.ErrorResponse do
|
||||
|
||||
@impl MusicLibrary.ErrorResponse
|
||||
@spec retry_delay_seconds(t()) :: pos_integer()
|
||||
def retry_delay_seconds(%__MODULE__{retry_delay_seconds: seconds}) when is_integer(seconds),
|
||||
do: seconds
|
||||
|
||||
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
|
||||
|
||||
@@ -123,7 +123,7 @@ defmodule Wikipedia.API do
|
||||
{request,
|
||||
%{status: 200, body: %{"error" => %{"code" => _, "info" => _}} = body} = response}
|
||||
) do
|
||||
error = ErrorResponse.from_action_api_body(body)
|
||||
error = ErrorResponse.from_action_api_body(body, response)
|
||||
|
||||
Logger.error(fn ->
|
||||
url = URI.to_string(request.url)
|
||||
|
||||
@@ -12,8 +12,8 @@ defmodule Wikipedia.API.ErrorResponse do
|
||||
* **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.
|
||||
`from_response/1` handles classic HTTP errors. `from_action_api_body/2` is a
|
||||
dedicated entry point for the HTTP 200 + body-error case.
|
||||
|
||||
## Non-error body shapes
|
||||
|
||||
@@ -32,36 +32,42 @@ defmodule Wikipedia.API.ErrorResponse do
|
||||
@behaviour MusicLibrary.ErrorResponse
|
||||
|
||||
alias MusicLibrary.HttpError
|
||||
alias MusicLibrary.RetryDelay
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
status: integer() | nil,
|
||||
code: String.t() | nil,
|
||||
message: String.t() | nil,
|
||||
kind: HttpError.kind(),
|
||||
body: term()
|
||||
body: term(),
|
||||
retry_delay_seconds: pos_integer() | nil
|
||||
}
|
||||
|
||||
defstruct [:status, :code, :message, :kind, :body]
|
||||
defstruct [:status, :code, :message, :kind, :body, :retry_delay_seconds]
|
||||
|
||||
@spec from_response(Req.Response.t() | map()) :: t()
|
||||
def from_response(%{status: status, body: body} = _response) do
|
||||
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
|
||||
body: body,
|
||||
retry_delay_seconds: RetryDelay.retry_after_seconds(response)
|
||||
}
|
||||
end
|
||||
|
||||
@spec from_action_api_body(map()) :: t()
|
||||
def from_action_api_body(%{"error" => %{"code" => code, "info" => info}} = body) do
|
||||
@spec from_action_api_body(map(), Req.Response.t() | map()) :: t()
|
||||
def from_action_api_body(body, response \\ %{})
|
||||
|
||||
def from_action_api_body(%{"error" => %{"code" => code, "info" => info}} = body, response) do
|
||||
%__MODULE__{
|
||||
status: 200,
|
||||
code: code,
|
||||
message: info,
|
||||
kind: action_api_kind(code),
|
||||
body: body
|
||||
body: body,
|
||||
retry_delay_seconds: RetryDelay.retry_after_seconds(response)
|
||||
}
|
||||
end
|
||||
|
||||
@@ -74,6 +80,9 @@ defmodule Wikipedia.API.ErrorResponse do
|
||||
|
||||
@impl MusicLibrary.ErrorResponse
|
||||
@spec retry_delay_seconds(t()) :: pos_integer()
|
||||
def retry_delay_seconds(%__MODULE__{retry_delay_seconds: seconds}) when is_integer(seconds),
|
||||
do: seconds
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user