ML-21: Classify API errors as transient vs permanent
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user