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
+77
View File
@@ -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}