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
+34 -3
View File
@@ -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
+2 -2
View File
@@ -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
+43
View File
@@ -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
+62
View File
@@ -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
+85
View File
@@ -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
+2 -1
View File
@@ -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
+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
+71 -11
View File
@@ -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
+164
View File
@@ -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
+37
View File
@@ -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