ML-146: honour API retry headers
This commit is contained in:
@@ -57,6 +57,7 @@ defmodule BraveSearch.APITest do
|
||||
Req.Test.stub(__MODULE__, fn conn ->
|
||||
conn
|
||||
|> Plug.Conn.put_status(429)
|
||||
|> Plug.Conn.put_resp_header("x-ratelimit-reset", "12, 60")
|
||||
|> Req.Test.json(%{
|
||||
"type" => "ErrorResponse",
|
||||
"error" => %{"status" => 429, "code" => "RATE_LIMITED", "detail" => "Too many requests"}
|
||||
@@ -69,6 +70,8 @@ defmodule BraveSearch.APITest do
|
||||
assert err.status == 429
|
||||
assert err.code == "RATE_LIMITED"
|
||||
assert err.kind == :rate_limit
|
||||
assert err.retry_delay_seconds == 60
|
||||
assert ErrorResponse.retry_delay_seconds(err) == 60
|
||||
assert ErrorResponse.retryable?(err)
|
||||
end
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ defmodule MusicBrainz.APITest do
|
||||
Req.Test.stub(__MODULE__, fn conn ->
|
||||
conn
|
||||
|> Plug.Conn.put_status(503)
|
||||
|> Plug.Conn.put_resp_header("retry-after", "42")
|
||||
|> Req.Test.json(%{"error" => "Your requests are exceeding the allowable rate limit."})
|
||||
end)
|
||||
|
||||
@@ -23,6 +24,8 @@ defmodule MusicBrainz.APITest do
|
||||
|
||||
assert err.status == 503
|
||||
assert err.kind == :rate_limit
|
||||
assert err.retry_delay_seconds == 42
|
||||
assert API.ErrorResponse.retry_delay_seconds(err) == 42
|
||||
assert API.ErrorResponse.retryable?(err)
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
defmodule MusicLibrary.RetryDelayTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias MusicLibrary.RetryDelay
|
||||
|
||||
describe "retry_after_seconds/1" do
|
||||
test "parses integer seconds" do
|
||||
response = response(%{"retry-after" => ["42"]})
|
||||
|
||||
assert RetryDelay.retry_after_seconds(response) == 42
|
||||
end
|
||||
|
||||
test "clamps parsed values to safe bounds" do
|
||||
assert RetryDelay.retry_after_seconds(response(%{"retry-after" => ["1"]})) == 5
|
||||
assert RetryDelay.retry_after_seconds(response(%{"retry-after" => ["999"]})) == 300
|
||||
end
|
||||
|
||||
test "returns nil for missing or malformed values" do
|
||||
assert RetryDelay.retry_after_seconds(response(%{})) == nil
|
||||
assert RetryDelay.retry_after_seconds(response(%{"retry-after" => ["tomorrow"]})) == nil
|
||||
assert RetryDelay.retry_after_seconds(response(%{"retry-after" => ["-1"]})) == nil
|
||||
end
|
||||
|
||||
test "clamps zero to the safe minimum" do
|
||||
assert RetryDelay.retry_after_seconds(response(%{"retry-after" => ["0"]})) == 5
|
||||
end
|
||||
end
|
||||
|
||||
describe "reset_seconds/2" do
|
||||
test "uses the largest valid comma-separated reset window" do
|
||||
response = response(%{"x-ratelimit-reset" => ["12, 60"]})
|
||||
|
||||
assert RetryDelay.reset_seconds(response, "x-ratelimit-reset") == 60
|
||||
end
|
||||
|
||||
test "ignores malformed windows and clamps the selected value" do
|
||||
response = response(%{"x-ratelimit-reset" => ["bad, 2, 600"]})
|
||||
|
||||
assert RetryDelay.reset_seconds(response, "x-ratelimit-reset") == 300
|
||||
end
|
||||
end
|
||||
|
||||
describe "openai_reset_seconds/1" do
|
||||
test "uses the largest reset across retry-after, request, and token windows" do
|
||||
response =
|
||||
response(%{
|
||||
"retry-after" => ["10"],
|
||||
"x-ratelimit-reset-requests" => ["20s"],
|
||||
"x-ratelimit-reset-tokens" => ["1m30s"]
|
||||
})
|
||||
|
||||
assert RetryDelay.openai_reset_seconds(response) == 90
|
||||
end
|
||||
|
||||
test "clamps sub-second OpenAI durations to the safe minimum" do
|
||||
response = response(%{"x-ratelimit-reset-requests" => ["120ms"]})
|
||||
|
||||
assert RetryDelay.openai_reset_seconds(response) == 5
|
||||
end
|
||||
|
||||
test "parses minute durations and clamps long values" do
|
||||
response = response(%{"x-ratelimit-reset-tokens" => ["10m"]})
|
||||
|
||||
assert RetryDelay.openai_reset_seconds(response) == 300
|
||||
end
|
||||
|
||||
test "returns nil when OpenAI reset headers are absent or malformed" do
|
||||
assert RetryDelay.openai_reset_seconds(response(%{})) == nil
|
||||
|
||||
assert RetryDelay.openai_reset_seconds(
|
||||
response(%{"x-ratelimit-reset-requests" => ["soon"]})
|
||||
) == nil
|
||||
end
|
||||
end
|
||||
|
||||
defp response(headers), do: Req.Response.new(status: 429, headers: headers)
|
||||
end
|
||||
@@ -22,6 +22,19 @@ defmodule MusicLibrary.Worker.ErrorHandlerTest do
|
||||
assert ErrorHandler.to_oban_result({:error, err}) == {:snooze, 60}
|
||||
end
|
||||
|
||||
test "parsed retry headers determine the snooze duration" do
|
||||
response =
|
||||
Req.Response.new(
|
||||
status: 503,
|
||||
headers: %{"retry-after" => ["42"]},
|
||||
body: %{"error" => "rate"}
|
||||
)
|
||||
|
||||
err = MusicBrainz.API.ErrorResponse.from_response(response)
|
||||
|
||||
assert ErrorHandler.to_oban_result({:error, err}) == {:snooze, 42}
|
||||
end
|
||||
|
||||
test "Discogs 429 → {:snooze, 60}" do
|
||||
err = Discogs.API.ErrorResponse.from_response(%{status: 429, body: %{"message" => "rate"}})
|
||||
assert ErrorHandler.to_oban_result({:error, err}) == {:snooze, 60}
|
||||
|
||||
@@ -31,6 +31,9 @@ defmodule OpenAI.APITest do
|
||||
Req.Test.stub(__MODULE__, fn conn ->
|
||||
conn
|
||||
|> Plug.Conn.put_status(429)
|
||||
|> Plug.Conn.put_resp_header("retry-after", "10")
|
||||
|> Plug.Conn.put_resp_header("x-ratelimit-reset-requests", "20s")
|
||||
|> Plug.Conn.put_resp_header("x-ratelimit-reset-tokens", "1m30s")
|
||||
|> Req.Test.json(%{
|
||||
"error" => %{
|
||||
"code" => "rate_limit_exceeded",
|
||||
@@ -46,6 +49,8 @@ defmodule OpenAI.APITest do
|
||||
assert err.status == 429
|
||||
assert err.code == "rate_limit_exceeded"
|
||||
assert err.kind == :rate_limit
|
||||
assert err.retry_delay_seconds == 90
|
||||
assert ErrorResponse.retry_delay_seconds(err) == 90
|
||||
assert ErrorResponse.retryable?(err)
|
||||
end
|
||||
|
||||
|
||||
@@ -14,7 +14,9 @@ defmodule Wikipedia.APITest do
|
||||
@tag :capture_log
|
||||
test "promotes ratelimited Action API error into a retryable ErrorResponse" do
|
||||
Req.Test.stub(API, fn conn ->
|
||||
Req.Test.json(conn, %{
|
||||
conn
|
||||
|> Plug.Conn.put_resp_header("retry-after", "42")
|
||||
|> Req.Test.json(%{
|
||||
"error" => %{"code" => "ratelimited", "info" => "You've exceeded your rate limit."}
|
||||
})
|
||||
end)
|
||||
@@ -23,6 +25,8 @@ defmodule Wikipedia.APITest do
|
||||
assert err.status == 200
|
||||
assert err.code == "ratelimited"
|
||||
assert err.kind == :rate_limit
|
||||
assert err.retry_delay_seconds == 42
|
||||
assert ErrorResponse.retry_delay_seconds(err) == 42
|
||||
assert ErrorResponse.retryable?(err)
|
||||
end
|
||||
|
||||
@@ -148,6 +152,7 @@ defmodule Wikipedia.APITest do
|
||||
Req.Test.stub(API, fn conn ->
|
||||
conn
|
||||
|> Plug.Conn.put_status(429)
|
||||
|> Plug.Conn.put_resp_header("retry-after", "25")
|
||||
|> Req.Test.json(%{
|
||||
"httpCode" => 429,
|
||||
"httpReason" => "Too Many Requests",
|
||||
@@ -158,6 +163,8 @@ defmodule Wikipedia.APITest do
|
||||
assert {:error, %ErrorResponse{status: 429, kind: :rate_limit} = err} =
|
||||
API.get_article_summary("Steven%20Wilson", @config)
|
||||
|
||||
assert err.retry_delay_seconds == 25
|
||||
assert ErrorResponse.retry_delay_seconds(err) == 25
|
||||
assert ErrorResponse.retryable?(err)
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user