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
@@ -298,10 +298,10 @@ defmodule MusicLibrary.Records.SimilarityTest do
Req.Test.stub(OpenAI.API, fn conn ->
conn
|> Plug.Conn.put_status(500)
|> Req.Test.json(%{"error" => "internal server error"})
|> Req.Test.json(%{"error" => %{"message" => "internal server error"}})
end)
assert {:error, %{"error" => "internal server error"}} =
assert {:error, %OpenAI.API.ErrorResponse{status: 500, kind: :server_error}} =
Similarity.generate_embedding(record)
end
end
+7 -2
View File
@@ -383,10 +383,15 @@ defmodule MusicLibrary.RecordsTest do
record = record(%{genres: []})
Req.Test.stub(OpenAI.API, fn conn ->
Plug.Conn.send_resp(conn, 500, JSON.encode!(%{"error" => "internal server error"}))
Plug.Conn.send_resp(
conn,
500,
JSON.encode!(%{"error" => %{"message" => "internal server error"}})
)
end)
assert {:error, "OpenAI API error:" <> _} = Records.populate_genres(record)
assert {:error, %OpenAI.API.ErrorResponse{status: 500, kind: :server_error}} =
Records.populate_genres(record)
end
end
end
@@ -0,0 +1,97 @@
defmodule MusicLibrary.Worker.ErrorHandlerTest do
use ExUnit.Case, async: true
alias MusicLibrary.Worker.ErrorHandler
describe "to_oban_result/1 — successes" do
test ":ok passes through" do
assert ErrorHandler.to_oban_result(:ok) == :ok
end
test "{:ok, result} passes through unchanged" do
payload = %{anything: true}
assert ErrorHandler.to_oban_result({:ok, payload}) == {:ok, payload}
end
end
describe "to_oban_result/1 — retryable ErrorResponses snooze" do
test "MusicBrainz 503 → {:snooze, 60}" do
err =
MusicBrainz.API.ErrorResponse.from_response(%{status: 503, body: %{"error" => "rate"}})
assert ErrorHandler.to_oban_result({:error, err}) == {:snooze, 60}
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}
end
test "Wikipedia Action API ratelimited → {:snooze, 30}" do
err =
Wikipedia.API.ErrorResponse.from_action_api_body(%{
"error" => %{"code" => "ratelimited", "info" => "rate"}
})
assert ErrorHandler.to_oban_result({:error, err}) == {:snooze, 30}
end
test "BraveSearch 500 → {:snooze, 30}" do
err =
BraveSearch.API.ErrorResponse.from_response(%{status: 500, body: %{"error" => "boom"}})
assert ErrorHandler.to_oban_result({:error, err}) == {:snooze, 30}
end
test "OpenAI 429 rate_limit_exceeded → {:snooze, 60}" do
err =
OpenAI.API.ErrorResponse.from_response(%{
status: 429,
body: %{"error" => %{"code" => "rate_limit_exceeded", "message" => "rate"}}
})
assert ErrorHandler.to_oban_result({:error, err}) == {:snooze, 60}
end
test "Last.fm rate_limit_exceeded → {:snooze, 60}" do
err = LastFm.API.ErrorResponse.new(29, "Rate limit exceeded")
assert ErrorHandler.to_oban_result({:error, err}) == {:snooze, 60}
end
end
describe "to_oban_result/1 — non-retryable ErrorResponses cancel" do
test "MusicBrainz 404 → {:cancel, err}" do
err = MusicBrainz.API.ErrorResponse.from_response(%{status: 404, body: %{"error" => "nf"}})
assert {:cancel, ^err} = ErrorHandler.to_oban_result({:error, err})
end
test "OpenAI 429 insufficient_quota → {:cancel, err}" do
err =
OpenAI.API.ErrorResponse.from_response(%{
status: 429,
body: %{"error" => %{"code" => "insufficient_quota", "message" => "quota"}}
})
assert {:cancel, ^err} = ErrorHandler.to_oban_result({:error, err})
end
test "Last.fm invalid_session_key → {:cancel, err}" do
err = LastFm.API.ErrorResponse.new(9, "Invalid session key")
assert {:cancel, ^err} = ErrorHandler.to_oban_result({:error, err})
end
end
describe "to_oban_result/1 — passthrough for unstructured errors and atom reasons" do
test "unknown {:error, reason} passes through unchanged" do
assert ErrorHandler.to_oban_result({:error, :something_else}) == {:error, :something_else}
end
test "{:cancel, _} passes through unchanged" do
assert ErrorHandler.to_oban_result({:cancel, :manual}) == {:cancel, :manual}
end
test "{:snooze, _} passes through unchanged" do
assert ErrorHandler.to_oban_result({:snooze, 120}) == {:snooze, 120}
end
end
end
@@ -44,16 +44,16 @@ defmodule MusicLibrary.Worker.GenerateRecordEmbeddingTest do
end
@tag :capture_log
test "returns {:error, reason} when OpenAI API fails" do
test "snoozes when OpenAI API fails with a 5xx (transient)" do
record = record()
Req.Test.stub(OpenAI.API, fn conn ->
conn
|> Plug.Conn.put_status(500)
|> Req.Test.json(%{"error" => "internal server error"})
|> Req.Test.json(%{"error" => %{"message" => "internal server error"}})
end)
assert {:error, %{"error" => "internal server error"}} =
assert {:snooze, 30} =
perform_job(GenerateRecordEmbedding, %{"record_id" => record.id})
end
@@ -31,14 +31,38 @@ defmodule MusicLibrary.Worker.PopulateGenresTest do
end
@tag :capture_log
test "returns error when OpenAI API fails" do
test "snoozes when OpenAI API returns a 5xx (transient)" do
record = record(%{genres: []})
Req.Test.stub(OpenAI.API, fn conn ->
Plug.Conn.send_resp(conn, 500, JSON.encode!(%{"error" => "internal server error"}))
conn
|> Plug.Conn.put_status(500)
|> Req.Test.json(%{"error" => %{"message" => "internal server error"}})
end)
assert {:error, "OpenAI API error:" <> _} =
assert {:snooze, 30} = perform_job(PopulateGenres, %{"id" => record.id})
end
@tag :capture_log
test "cancels when OpenAI API returns 429 insufficient_quota (permanent)" do
record = record(%{genres: []})
Req.Test.stub(OpenAI.API, fn conn ->
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(
429,
JSON.encode!(%{
"error" => %{
"code" => "insufficient_quota",
"type" => "insufficient_quota",
"message" => "quota exceeded"
}
})
)
end)
assert {:cancel, %OpenAI.API.ErrorResponse{code: "insufficient_quota"}} =
perform_job(PopulateGenres, %{"id" => record.id})
end
end