ML-21: Classify API errors as transient vs permanent
This commit is contained in:
@@ -2,6 +2,7 @@ defmodule BraveSearch.APITest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias BraveSearch.API
|
||||
alias BraveSearch.API.ErrorResponse
|
||||
|
||||
@config %BraveSearch.Config{
|
||||
api_key: "test_key",
|
||||
@@ -52,14 +53,44 @@ defmodule BraveSearch.APITest do
|
||||
end
|
||||
|
||||
@tag :capture_log
|
||||
test "returns error on non-200 response" do
|
||||
test "returns a rate-limit ErrorResponse on 429" do
|
||||
Req.Test.stub(__MODULE__, fn conn ->
|
||||
conn
|
||||
|> Plug.Conn.put_status(429)
|
||||
|> Req.Test.json(%{"error" => "rate limited"})
|
||||
|> Req.Test.json(%{
|
||||
"type" => "ErrorResponse",
|
||||
"error" => %{"status" => 429, "code" => "RATE_LIMITED", "detail" => "Too many requests"}
|
||||
})
|
||||
end)
|
||||
|
||||
assert {:error, %{"error" => "rate limited"}} = API.search_images("test", [], @config)
|
||||
assert {:error, %ErrorResponse{} = err} =
|
||||
API.search_images("test", [], @config)
|
||||
|
||||
assert err.status == 429
|
||||
assert err.code == "RATE_LIMITED"
|
||||
assert err.kind == :rate_limit
|
||||
assert ErrorResponse.retryable?(err)
|
||||
end
|
||||
|
||||
@tag :capture_log
|
||||
test "returns a client-error ErrorResponse on 422" do
|
||||
Req.Test.stub(__MODULE__, fn conn ->
|
||||
conn
|
||||
|> Plug.Conn.put_status(422)
|
||||
|> Req.Test.json(%{
|
||||
"type" => "ErrorResponse",
|
||||
"error" => %{
|
||||
"status" => 422,
|
||||
"code" => "SUBSCRIPTION_TOKEN_INVALID",
|
||||
"detail" => "The provided subscription token is invalid."
|
||||
}
|
||||
})
|
||||
end)
|
||||
|
||||
assert {:error, %ErrorResponse{kind: :client_error} = err} =
|
||||
API.search_images("test", [], @config)
|
||||
|
||||
refute ErrorResponse.retryable?(err)
|
||||
end
|
||||
|
||||
test "passes count option as query param" do
|
||||
|
||||
@@ -76,14 +76,14 @@ defmodule BraveSearchTest do
|
||||
end
|
||||
|
||||
@tag :capture_log
|
||||
test "returns the decoded error body on non-200 responses" do
|
||||
test "returns an ErrorResponse struct on non-200 responses" do
|
||||
Req.Test.stub(BraveSearch.API, fn conn ->
|
||||
conn
|
||||
|> Plug.Conn.put_resp_content_type("application/json")
|
||||
|> Plug.Conn.send_resp(429, ~s({"error":"rate_limited"}))
|
||||
end)
|
||||
|
||||
assert {:error, %{"error" => "rate_limited"}} =
|
||||
assert {:error, %BraveSearch.API.ErrorResponse{status: 429, kind: :rate_limit}} =
|
||||
BraveSearch.search_images("too many requests")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
defmodule DiscogsTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Discogs.API.ErrorResponse
|
||||
alias Discogs.Fixtures
|
||||
|
||||
describe "get_artist/1" do
|
||||
@@ -16,5 +17,47 @@ defmodule DiscogsTest do
|
||||
|
||||
assert {:ok, expected_info} == Discogs.get_artist(discogs_id)
|
||||
end
|
||||
|
||||
@tag :capture_log
|
||||
test "returns a rate-limit ErrorResponse on 429" do
|
||||
Req.Test.stub(Discogs.API, fn conn ->
|
||||
conn
|
||||
|> Plug.Conn.put_status(429)
|
||||
|> Req.Test.json(%{"message" => "You are making requests too quickly."})
|
||||
end)
|
||||
|
||||
assert {:error, %ErrorResponse{} = err} = Discogs.get_artist("1")
|
||||
assert err.status == 429
|
||||
assert err.kind == :rate_limit
|
||||
assert ErrorResponse.retryable?(err)
|
||||
end
|
||||
|
||||
@tag :capture_log
|
||||
test "returns a not-found ErrorResponse on 404" do
|
||||
Req.Test.stub(Discogs.API, fn conn ->
|
||||
conn
|
||||
|> Plug.Conn.put_status(404)
|
||||
|> Req.Test.json(%{"message" => "The requested resource was not found."})
|
||||
end)
|
||||
|
||||
assert {:error, %ErrorResponse{kind: :not_found} = err} =
|
||||
Discogs.get_artist("1")
|
||||
|
||||
refute ErrorResponse.retryable?(err)
|
||||
end
|
||||
|
||||
@tag :capture_log
|
||||
test "returns a server-error ErrorResponse on 500" do
|
||||
Req.Test.stub(Discogs.API, fn conn ->
|
||||
conn
|
||||
|> Plug.Conn.put_status(500)
|
||||
|> Req.Test.json(%{"message" => "Internal server error"})
|
||||
end)
|
||||
|
||||
assert {:error, %ErrorResponse{kind: :server_error} = err} =
|
||||
Discogs.get_artist("1")
|
||||
|
||||
assert ErrorResponse.retryable?(err)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
defmodule LastFm.API.ErrorResponseTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias LastFm.API.ErrorResponse
|
||||
|
||||
describe "new/2" do
|
||||
test "maps known error codes to atoms" do
|
||||
assert %ErrorResponse{error: :rate_limit_exceeded, message: "limit"} =
|
||||
ErrorResponse.new(29, "limit")
|
||||
|
||||
assert %ErrorResponse{error: :invalid_session_key} = ErrorResponse.new(9, "bad")
|
||||
assert %ErrorResponse{error: :service_offline} = ErrorResponse.new(11, "down")
|
||||
end
|
||||
end
|
||||
|
||||
describe "retryable_error?/1" do
|
||||
test "returns true for transient error atoms" do
|
||||
for atom <- [:transient_error, :service_offline, :rate_limit_exceeded, :operation_failed] do
|
||||
assert ErrorResponse.retryable_error?(atom), "expected #{atom} to be retryable"
|
||||
end
|
||||
end
|
||||
|
||||
test "returns false for permanent error atoms" do
|
||||
for atom <- [:invalid_session_key, :invalid_api_key, :authentication_failed] do
|
||||
refute ErrorResponse.retryable_error?(atom), "expected #{atom} to be non-retryable"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "retry_delay/1 and retry_delay_seconds/1" do
|
||||
test "returns a delay in milliseconds for retryable atoms" do
|
||||
assert ErrorResponse.retry_delay(:rate_limit_exceeded) == 60_000
|
||||
assert ErrorResponse.retry_delay(:service_offline) == 30_000
|
||||
assert ErrorResponse.retry_delay(:transient_error) == 5_000
|
||||
assert ErrorResponse.retry_delay(:operation_failed) == 5_000
|
||||
end
|
||||
|
||||
test "returns nil for non-retryable atoms" do
|
||||
for atom <- [:invalid_session_key, :invalid_api_key, :authentication_failed] do
|
||||
assert ErrorResponse.retry_delay(atom) == nil,
|
||||
"expected #{atom} to have no retry delay"
|
||||
end
|
||||
end
|
||||
|
||||
test "struct-based retry_delay_seconds/1 returns the delay in seconds" do
|
||||
assert ErrorResponse.retry_delay_seconds(ErrorResponse.new(29, "")) == 60
|
||||
assert ErrorResponse.retry_delay_seconds(ErrorResponse.new(11, "")) == 30
|
||||
assert ErrorResponse.retry_delay_seconds(ErrorResponse.new(16, "")) == 5
|
||||
end
|
||||
|
||||
test "struct-based retry_delay_seconds/1 falls back to 30 s for non-retryable atoms" do
|
||||
assert ErrorResponse.retry_delay_seconds(ErrorResponse.new(9, "")) == 30
|
||||
end
|
||||
end
|
||||
|
||||
describe "retryable?/1 (struct-based)" do
|
||||
test "agrees with retryable_error?/1" do
|
||||
assert ErrorResponse.retryable?(ErrorResponse.new(29, ""))
|
||||
refute ErrorResponse.retryable?(ErrorResponse.new(9, ""))
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,85 @@
|
||||
defmodule MusicBrainz.APITest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias MusicBrainz.API
|
||||
|
||||
@config %MusicBrainz.Config{
|
||||
user_agent: "test_agent",
|
||||
req_options: [plug: {Req.Test, __MODULE__}, max_retries: 0],
|
||||
api_cooldown: 0
|
||||
}
|
||||
|
||||
describe "error classification" do
|
||||
@tag :capture_log
|
||||
test "503 is treated as :rate_limit (MusicBrainz's rate-limit signal, not 429)" do
|
||||
Req.Test.stub(__MODULE__, fn conn ->
|
||||
conn
|
||||
|> Plug.Conn.put_status(503)
|
||||
|> Req.Test.json(%{"error" => "Your requests are exceeding the allowable rate limit."})
|
||||
end)
|
||||
|
||||
assert {:error, %API.ErrorResponse{} = err} =
|
||||
API.get_artist("mbid", @config)
|
||||
|
||||
assert err.status == 503
|
||||
assert err.kind == :rate_limit
|
||||
assert API.ErrorResponse.retryable?(err)
|
||||
end
|
||||
|
||||
@tag :capture_log
|
||||
test "429 falls under :rate_limit via default mapping (even though MusicBrainz doesn't use it)" do
|
||||
Req.Test.stub(__MODULE__, fn conn ->
|
||||
conn
|
||||
|> Plug.Conn.put_status(429)
|
||||
|> Req.Test.json(%{"error" => "Too many requests"})
|
||||
end)
|
||||
|
||||
assert {:error, %API.ErrorResponse{kind: :rate_limit} = err} =
|
||||
API.get_artist("mbid", @config)
|
||||
|
||||
assert API.ErrorResponse.retryable?(err)
|
||||
end
|
||||
|
||||
@tag :capture_log
|
||||
test "404 is treated as :not_found and is not retryable" do
|
||||
Req.Test.stub(__MODULE__, fn conn ->
|
||||
conn
|
||||
|> Plug.Conn.put_status(404)
|
||||
|> Req.Test.json(%{"error" => "Not Found"})
|
||||
end)
|
||||
|
||||
assert {:error, %API.ErrorResponse{kind: :not_found} = err} =
|
||||
API.get_artist("mbid", @config)
|
||||
|
||||
refute API.ErrorResponse.retryable?(err)
|
||||
end
|
||||
|
||||
@tag :capture_log
|
||||
test "400 is treated as :client_error and is not retryable" do
|
||||
Req.Test.stub(__MODULE__, fn conn ->
|
||||
conn
|
||||
|> Plug.Conn.put_status(400)
|
||||
|> Req.Test.json(%{"error" => "Invalid MBID"})
|
||||
end)
|
||||
|
||||
assert {:error, %API.ErrorResponse{kind: :client_error} = err} =
|
||||
API.get_artist("mbid", @config)
|
||||
|
||||
refute API.ErrorResponse.retryable?(err)
|
||||
end
|
||||
|
||||
@tag :capture_log
|
||||
test "500 is treated as :server_error and is retryable" do
|
||||
Req.Test.stub(__MODULE__, fn conn ->
|
||||
conn
|
||||
|> Plug.Conn.put_status(500)
|
||||
|> Req.Test.json(%{"error" => "Internal Server Error"})
|
||||
end)
|
||||
|
||||
assert {:error, %API.ErrorResponse{kind: :server_error} = err} =
|
||||
API.get_artist("mbid", @config)
|
||||
|
||||
assert API.ErrorResponse.retryable?(err)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -121,6 +121,7 @@ defmodule MusicBrainzTest do
|
||||
assert releases == []
|
||||
end
|
||||
|
||||
@tag :capture_log
|
||||
test "error on a later page is returned immediately" do
|
||||
full_page = release_page(100)
|
||||
|
||||
@@ -138,7 +139,7 @@ defmodule MusicBrainzTest do
|
||||
end
|
||||
end)
|
||||
|
||||
assert {:error, %{"error" => "service unavailable"}} =
|
||||
assert {:error, %MusicBrainz.API.ErrorResponse{status: 503, kind: :rate_limit}} =
|
||||
MusicBrainz.get_all_releases(@release_group_id)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
+71
-11
@@ -2,6 +2,7 @@ defmodule OpenAI.APITest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias OpenAI.API
|
||||
alias OpenAI.API.ErrorResponse
|
||||
|
||||
@config %OpenAI.Config{
|
||||
api_key: "test_key",
|
||||
@@ -26,15 +27,62 @@ defmodule OpenAI.APITest do
|
||||
end
|
||||
|
||||
@tag :capture_log
|
||||
test "returns error on non-2xx response" do
|
||||
test "returns a rate-limit error response on 429 rate_limit_exceeded" do
|
||||
Req.Test.stub(__MODULE__, fn conn ->
|
||||
conn
|
||||
|> Plug.Conn.put_status(429)
|
||||
|> Req.Test.json(%{"error" => "rate limited"})
|
||||
|> Req.Test.json(%{
|
||||
"error" => %{
|
||||
"code" => "rate_limit_exceeded",
|
||||
"type" => "rate_limit_error",
|
||||
"message" => "Rate limit reached"
|
||||
}
|
||||
})
|
||||
end)
|
||||
|
||||
completion = %{model: "gpt-4.1-mini", content: "test", role: "user", temperature: 0.5}
|
||||
assert {:error, "OpenAI API error:" <> _} = API.gpt(completion, @config)
|
||||
|
||||
assert {:error, %ErrorResponse{} = err} = API.gpt(completion, @config)
|
||||
assert err.status == 429
|
||||
assert err.code == "rate_limit_exceeded"
|
||||
assert err.kind == :rate_limit
|
||||
assert ErrorResponse.retryable?(err)
|
||||
end
|
||||
|
||||
@tag :capture_log
|
||||
test "returns an auth-error response on 429 insufficient_quota (non-retryable)" do
|
||||
Req.Test.stub(__MODULE__, fn conn ->
|
||||
conn
|
||||
|> Plug.Conn.put_status(429)
|
||||
|> Req.Test.json(%{
|
||||
"error" => %{
|
||||
"code" => "insufficient_quota",
|
||||
"type" => "insufficient_quota",
|
||||
"message" => "You exceeded your current quota"
|
||||
}
|
||||
})
|
||||
end)
|
||||
|
||||
completion = %{model: "gpt-4.1-mini", content: "test", role: "user", temperature: 0.5}
|
||||
|
||||
assert {:error, %ErrorResponse{} = err} = API.gpt(completion, @config)
|
||||
assert err.code == "insufficient_quota"
|
||||
assert err.kind == :auth_error
|
||||
refute ErrorResponse.retryable?(err)
|
||||
end
|
||||
|
||||
@tag :capture_log
|
||||
test "returns a server-error response on 500" do
|
||||
Req.Test.stub(__MODULE__, fn conn ->
|
||||
conn
|
||||
|> Plug.Conn.put_status(500)
|
||||
|> Req.Test.json(%{"error" => %{"message" => "boom"}})
|
||||
end)
|
||||
|
||||
completion = %{model: "gpt-4.1-mini", content: "test", role: "user", temperature: 0.5}
|
||||
|
||||
assert {:error, %ErrorResponse{kind: :server_error}} =
|
||||
API.gpt(completion, @config)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -54,14 +102,26 @@ defmodule OpenAI.APITest do
|
||||
end
|
||||
|
||||
@tag :capture_log
|
||||
test "returns error on non-200 response" do
|
||||
test "returns a retryable server-error ErrorResponse on 500" do
|
||||
Req.Test.stub(__MODULE__, fn conn ->
|
||||
conn
|
||||
|> Plug.Conn.put_status(500)
|
||||
|> Req.Test.json(%{"error" => "internal error"})
|
||||
|> Req.Test.json(%{"error" => %{"message" => "internal error"}})
|
||||
end)
|
||||
|
||||
assert {:error, %{"error" => "internal error"}} = API.get_embeddings("test text", @config)
|
||||
assert {:error, %ErrorResponse{status: 500, kind: :server_error} = err} =
|
||||
API.get_embeddings("test text", @config)
|
||||
|
||||
assert ErrorResponse.retryable?(err)
|
||||
end
|
||||
|
||||
test "returns the transport exception on connection failure" do
|
||||
Req.Test.stub(__MODULE__, fn conn ->
|
||||
Req.Test.transport_error(conn, :timeout)
|
||||
end)
|
||||
|
||||
assert {:error, %Req.TransportError{reason: :timeout}} =
|
||||
API.get_embeddings("test text", @config)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -114,27 +174,27 @@ defmodule OpenAI.APITest do
|
||||
end
|
||||
|
||||
@tag :capture_log
|
||||
test "returns error on non-2xx response" do
|
||||
test "returns an ErrorResponse struct on non-2xx response" do
|
||||
Req.Test.stub(__MODULE__, fn conn ->
|
||||
conn
|
||||
|> Plug.Conn.put_status(500)
|
||||
|> Req.Test.json(%{"error" => "server error"})
|
||||
|> Req.Test.json(%{"error" => %{"message" => "server error"}})
|
||||
end)
|
||||
|
||||
cb = fn _chunk -> :ok end
|
||||
|
||||
assert {:error, "OpenAI API error:" <> _} =
|
||||
assert {:error, %ErrorResponse{status: 500, kind: :server_error}} =
|
||||
API.chat_stream([], "instructions", "gpt-4.1", 0.7, @config, cb)
|
||||
end
|
||||
|
||||
test "returns error on connection failure" do
|
||||
test "returns the transport exception on connection failure" do
|
||||
Req.Test.stub(__MODULE__, fn conn ->
|
||||
Req.Test.transport_error(conn, :timeout)
|
||||
end)
|
||||
|
||||
cb = fn _chunk -> :ok end
|
||||
|
||||
assert {:error, "Connection error:" <> _} =
|
||||
assert {:error, %Req.TransportError{reason: :timeout}} =
|
||||
API.chat_stream([], "instructions", "gpt-4.1", 0.7, @config, cb)
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
defmodule Wikipedia.APITest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Wikipedia.API
|
||||
alias Wikipedia.API.ErrorResponse
|
||||
|
||||
@config %Wikipedia.Config{
|
||||
user_agent: "test_agent",
|
||||
req_options: [plug: {Req.Test, Wikipedia.API}, max_retries: 0],
|
||||
api_cooldown: 0
|
||||
}
|
||||
|
||||
describe "Action API silent-bug fix (HTTP 200 + body error envelope)" 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, %{
|
||||
"error" => %{"code" => "ratelimited", "info" => "You've exceeded your rate limit."}
|
||||
})
|
||||
end)
|
||||
|
||||
assert {:error, %ErrorResponse{} = err} = API.get_wikipedia_title("Q352766", @config)
|
||||
assert err.status == 200
|
||||
assert err.code == "ratelimited"
|
||||
assert err.kind == :rate_limit
|
||||
assert ErrorResponse.retryable?(err)
|
||||
end
|
||||
|
||||
@tag :capture_log
|
||||
test "promotes maxlag Action API error into a retryable ErrorResponse" do
|
||||
Req.Test.stub(API, fn conn ->
|
||||
Req.Test.json(conn, %{
|
||||
"error" => %{
|
||||
"code" => "maxlag",
|
||||
"info" => "Waiting for a database server: 7 seconds lagged."
|
||||
}
|
||||
})
|
||||
end)
|
||||
|
||||
assert {:error, %ErrorResponse{kind: :rate_limit} = err} =
|
||||
API.get_wikipedia_title("Q352766", @config)
|
||||
|
||||
assert err.code == "maxlag"
|
||||
assert ErrorResponse.retryable?(err)
|
||||
end
|
||||
|
||||
@tag :capture_log
|
||||
test "promotes readonly Action API error into a retryable server error" do
|
||||
Req.Test.stub(API, fn conn ->
|
||||
Req.Test.json(conn, %{
|
||||
"error" => %{"code" => "readonly", "info" => "The wiki is currently in read-only mode."}
|
||||
})
|
||||
end)
|
||||
|
||||
assert {:error, %ErrorResponse{kind: :server_error} = err} =
|
||||
API.get_wikipedia_title("Q352766", @config)
|
||||
|
||||
assert ErrorResponse.retryable?(err)
|
||||
end
|
||||
|
||||
@tag :capture_log
|
||||
test "promotes internal_api_error_* codes into retryable server errors" do
|
||||
Req.Test.stub(API, fn conn ->
|
||||
Req.Test.json(conn, %{
|
||||
"error" => %{
|
||||
"code" => "internal_api_error_DBQueryError",
|
||||
"info" => "[abc] Exception Caught"
|
||||
}
|
||||
})
|
||||
end)
|
||||
|
||||
assert {:error, %ErrorResponse{kind: :server_error} = err} =
|
||||
API.get_wikipedia_title("Q352766", @config)
|
||||
|
||||
assert ErrorResponse.retryable?(err)
|
||||
end
|
||||
|
||||
@tag :capture_log
|
||||
test "classifies unknown error codes as non-retryable client errors" do
|
||||
Req.Test.stub(API, fn conn ->
|
||||
Req.Test.json(conn, %{
|
||||
"error" => %{"code" => "badtoken", "info" => "Invalid CSRF token."}
|
||||
})
|
||||
end)
|
||||
|
||||
assert {:error, %ErrorResponse{kind: :client_error} = err} =
|
||||
API.get_wikipedia_title("Q352766", @config)
|
||||
|
||||
refute ErrorResponse.retryable?(err)
|
||||
end
|
||||
end
|
||||
|
||||
describe "Action API non-error body shapes still return {:ok, _}" do
|
||||
test "wbgetentities with a missing entity returns {:ok, body} — not an error" do
|
||||
Req.Test.stub(API, fn conn ->
|
||||
Req.Test.json(conn, %{
|
||||
"entities" => %{"Q99999999999" => %{"id" => "Q99999999999", "missing" => ""}}
|
||||
})
|
||||
end)
|
||||
|
||||
assert {:ok, nil} = API.get_wikipedia_title("Q99999999999", @config)
|
||||
end
|
||||
|
||||
test "wbgetentities with no enwiki sitelink returns {:ok, nil}" do
|
||||
Req.Test.stub(API, fn conn ->
|
||||
Req.Test.json(conn, %{
|
||||
"entities" => %{"Q352766" => %{"id" => "Q352766", "sitelinks" => %{}}}
|
||||
})
|
||||
end)
|
||||
|
||||
assert {:ok, nil} = API.get_wikipedia_title("Q352766", @config)
|
||||
end
|
||||
|
||||
test "query with empty extract returns {:ok, nil}" do
|
||||
Req.Test.stub(API, fn conn ->
|
||||
Req.Test.json(conn, %{
|
||||
"query" => %{
|
||||
"pages" => %{"123" => %{"pageid" => 123, "title" => "X", "extract" => nil}}
|
||||
}
|
||||
})
|
||||
end)
|
||||
|
||||
assert {:ok, nil} = API.get_article_extract("X", @config)
|
||||
end
|
||||
end
|
||||
|
||||
describe "REST v1 API error classification" do
|
||||
@tag :capture_log
|
||||
test "404 on article summary surfaces as a not-found ErrorResponse" do
|
||||
Req.Test.stub(API, fn conn ->
|
||||
conn
|
||||
|> Plug.Conn.put_status(404)
|
||||
|> Req.Test.json(%{
|
||||
"httpCode" => 404,
|
||||
"httpReason" => "Not Found",
|
||||
"messageTranslations" => %{"en" => "The specified title does not exist."}
|
||||
})
|
||||
end)
|
||||
|
||||
assert {:error, %ErrorResponse{status: 404, kind: :not_found} = err} =
|
||||
API.get_article_summary("Missing%20Title", @config)
|
||||
|
||||
refute ErrorResponse.retryable?(err)
|
||||
end
|
||||
|
||||
@tag :capture_log
|
||||
test "429 on article summary surfaces as a retryable rate-limit ErrorResponse" do
|
||||
Req.Test.stub(API, fn conn ->
|
||||
conn
|
||||
|> Plug.Conn.put_status(429)
|
||||
|> Req.Test.json(%{
|
||||
"httpCode" => 429,
|
||||
"httpReason" => "Too Many Requests",
|
||||
"messageTranslations" => %{"en" => "Slow down."}
|
||||
})
|
||||
end)
|
||||
|
||||
assert {:error, %ErrorResponse{status: 429, kind: :rate_limit} = err} =
|
||||
API.get_article_summary("Steven%20Wilson", @config)
|
||||
|
||||
assert ErrorResponse.retryable?(err)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,8 @@
|
||||
defmodule WikipediaTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Wikipedia.API.ErrorResponse
|
||||
|
||||
describe "get_artist_summary/1" do
|
||||
test "resolves wikidata ID to Wikipedia summary with full intro" do
|
||||
wikidata_id = "Q352766"
|
||||
@@ -38,5 +40,40 @@ defmodule WikipediaTest do
|
||||
|
||||
assert {:error, :no_english_wikipedia} = Wikipedia.get_artist_summary(wikidata_id)
|
||||
end
|
||||
|
||||
@tag :capture_log
|
||||
test "promotes HTTP 200 Action API error envelopes into ErrorResponse structs" do
|
||||
wikidata_id = "Q352766"
|
||||
|
||||
Req.Test.stub(Wikipedia.API, fn conn ->
|
||||
Req.Test.json(conn, %{
|
||||
"error" => %{"code" => "ratelimited", "info" => "You've exceeded your rate limit."}
|
||||
})
|
||||
end)
|
||||
|
||||
assert {:error, %ErrorResponse{} = err} =
|
||||
Wikipedia.get_artist_summary(wikidata_id)
|
||||
|
||||
assert err.status == 200
|
||||
assert err.code == "ratelimited"
|
||||
assert err.kind == :rate_limit
|
||||
assert ErrorResponse.retryable?(err)
|
||||
end
|
||||
|
||||
@tag :capture_log
|
||||
test "classifies non-retryable Action API error codes as client errors" do
|
||||
wikidata_id = "Q352766"
|
||||
|
||||
Req.Test.stub(Wikipedia.API, fn conn ->
|
||||
Req.Test.json(conn, %{
|
||||
"error" => %{"code" => "badtoken", "info" => "Invalid CSRF token."}
|
||||
})
|
||||
end)
|
||||
|
||||
assert {:error, %ErrorResponse{kind: :client_error} = err} =
|
||||
Wikipedia.get_artist_summary(wikidata_id)
|
||||
|
||||
refute ErrorResponse.retryable?(err)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user