ML-146: honour API retry headers

This commit is contained in:
Claudio Ortolina
2026-04-25 07:29:28 +01:00
parent d588e5738f
commit 9113df5879
14 changed files with 363 additions and 38 deletions
+10 -4
View File
@@ -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
+13 -6
View File
@@ -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
+133
View File
@@ -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
+15 -7
View File
@@ -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
+1 -1
View File
@@ -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)
+18 -9
View File
@@ -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