# frozen_string_literal: true require "webmock/rspec" RSpec.describe Dispatch::Adapter::Claude, "error mapping integration" do let(:model_id) { "claude-sonnet-4-5-20250929" } let(:api_key) { "sk-ant-api03-test" } let(:base_url) { "https://api.anthropic.com" } subject(:adapter) do described_class.new( model: model_id, api_key: api_key, base_url: base_url ) end let(:messages) do [Dispatch::Adapter::Message.new( role: "user", content: [Dispatch::Adapter::TextBlock.new(text: "Hello")] )] end before { WebMock.disable_net_connect! } after { WebMock.reset! } # ── Helpers ─────────────────────────────────────────────────────────────── def stub_api_error(status:, type:, message:, retry_after: nil) headers = { "Content-Type" => "application/json" } headers["Retry-After"] = retry_after.to_s if retry_after stub_request(:post, "#{base_url}/v1/messages") .to_return( status: status, body: JSON.generate({ "type" => "error", "error" => { "type" => type, "message" => message } }), headers: headers ) end def stub_network_error(error_class) stub_request(:post, "#{base_url}/v1/messages").to_raise(error_class) end # ── 400 invalid_request_error → RequestError ───────────────────────────── describe "400 invalid_request_error" do before { stub_api_error(status: 400, type: "invalid_request_error", message: "Bad request body") } it "raises RequestError" do expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::RequestError) end it "e.status_code == 400" do adapter.chat(messages) rescue Dispatch::Adapter::RequestError => e expect(e.status_code).to eq(400) end it "e.provider == 'Anthropic (Claude)'" do adapter.chat(messages) rescue Dispatch::Adapter::RequestError => e expect(e.provider).to eq("Anthropic (Claude)") end it "e.message includes the error message from body" do adapter.chat(messages) rescue Dispatch::Adapter::RequestError => e expect(e.message).to include("Bad request body") end end # ── 401 authentication_error → AuthenticationError ─────────────────────── describe "401 authentication_error" do before { stub_api_error(status: 401, type: "authentication_error", message: "Invalid API key") } it "raises AuthenticationError" do expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::AuthenticationError) end it "e.status_code == 401" do adapter.chat(messages) rescue Dispatch::Adapter::AuthenticationError => e expect(e.status_code).to eq(401) end it "e.provider == 'Anthropic (Claude)'" do adapter.chat(messages) rescue Dispatch::Adapter::AuthenticationError => e expect(e.provider).to eq("Anthropic (Claude)") end it "e.message includes the error message from body" do adapter.chat(messages) rescue Dispatch::Adapter::AuthenticationError => e expect(e.message).to include("Invalid API key") end end # ── 403 permission_error → AuthenticationError ─────────────────────────── describe "403 permission_error" do before { stub_api_error(status: 403, type: "permission_error", message: "Access denied") } it "raises AuthenticationError" do expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::AuthenticationError) end it "e.status_code == 403" do adapter.chat(messages) rescue Dispatch::Adapter::AuthenticationError => e expect(e.status_code).to eq(403) end it "e.provider == 'Anthropic (Claude)'" do adapter.chat(messages) rescue Dispatch::Adapter::AuthenticationError => e expect(e.provider).to eq("Anthropic (Claude)") end it "e.message includes the error message from body" do adapter.chat(messages) rescue Dispatch::Adapter::AuthenticationError => e expect(e.message).to include("Access denied") end end # ── 422 unprocessable_entity → RequestError ────────────────────────────── describe "422 unprocessable_entity" do before { stub_api_error(status: 422, type: "unprocessable_entity", message: "Invalid field value") } it "raises RequestError" do expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::RequestError) end it "e.status_code == 422" do adapter.chat(messages) rescue Dispatch::Adapter::RequestError => e expect(e.status_code).to eq(422) end it "e.provider == 'Anthropic (Claude)'" do adapter.chat(messages) rescue Dispatch::Adapter::RequestError => e expect(e.provider).to eq("Anthropic (Claude)") end it "e.message includes the error message from body" do adapter.chat(messages) rescue Dispatch::Adapter::RequestError => e expect(e.message).to include("Invalid field value") end end # ── 429 rate_limit_error → RateLimitError w/ retry_after ───────────────── describe "429 rate_limit_error" do before { stub_api_error(status: 429, type: "rate_limit_error", message: "Too many requests", retry_after: 30) } it "raises RateLimitError" do expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::RateLimitError) end it "e.status_code == 429" do adapter.chat(messages) rescue Dispatch::Adapter::RateLimitError => e expect(e.status_code).to eq(429) end it "e.provider == 'Anthropic (Claude)'" do adapter.chat(messages) rescue Dispatch::Adapter::RateLimitError => e expect(e.provider).to eq("Anthropic (Claude)") end it "e.retry_after == 30" do adapter.chat(messages) rescue Dispatch::Adapter::RateLimitError => e expect(e.retry_after).to eq(30) end it "e.message includes the error message from body" do adapter.chat(messages) rescue Dispatch::Adapter::RateLimitError => e expect(e.message).to include("Too many requests") end end # ── 500 internal_error → ServerError ───────────────────────────────────── describe "500 internal_error" do before { stub_api_error(status: 500, type: "internal_error", message: "Internal server error") } it "raises ServerError" do expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::ServerError) end it "e.status_code == 500" do adapter.chat(messages) rescue Dispatch::Adapter::ServerError => e expect(e.status_code).to eq(500) end it "e.provider == 'Anthropic (Claude)'" do adapter.chat(messages) rescue Dispatch::Adapter::ServerError => e expect(e.provider).to eq("Anthropic (Claude)") end it "e.message includes the error message from body" do adapter.chat(messages) rescue Dispatch::Adapter::ServerError => e expect(e.message).to include("Internal server error") end end # ── 502 bad_gateway → ServerError ──────────────────────────────────────── describe "502 bad_gateway" do before { stub_api_error(status: 502, type: "bad_gateway", message: "Bad gateway") } it "raises ServerError" do expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::ServerError) end it "e.status_code == 502" do adapter.chat(messages) rescue Dispatch::Adapter::ServerError => e expect(e.status_code).to eq(502) end it "e.provider == 'Anthropic (Claude)'" do adapter.chat(messages) rescue Dispatch::Adapter::ServerError => e expect(e.provider).to eq("Anthropic (Claude)") end it "e.message includes the error message from body" do adapter.chat(messages) rescue Dispatch::Adapter::ServerError => e expect(e.message).to include("Bad gateway") end end # ── 503 service_unavailable → ServerError ──────────────────────────────── describe "503 service_unavailable" do before { stub_api_error(status: 503, type: "service_unavailable", message: "Service temporarily unavailable") } it "raises ServerError" do expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::ServerError) end it "e.status_code == 503" do adapter.chat(messages) rescue Dispatch::Adapter::ServerError => e expect(e.status_code).to eq(503) end it "e.provider == 'Anthropic (Claude)'" do adapter.chat(messages) rescue Dispatch::Adapter::ServerError => e expect(e.provider).to eq("Anthropic (Claude)") end it "e.message includes the error message from body" do adapter.chat(messages) rescue Dispatch::Adapter::ServerError => e expect(e.message).to include("Service temporarily unavailable") end end # ── 529 overloaded_error → OverloadedError (subclass of RateLimitError) ── describe "529 overloaded_error" do before { stub_api_error(status: 529, type: "overloaded_error", message: "Overloaded", retry_after: 60) } it "raises OverloadedError" do expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::OverloadedError) end it "also caught by RateLimitError (OverloadedError is a subclass)" do expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::RateLimitError) end it "e.status_code == 529" do adapter.chat(messages) rescue Dispatch::Adapter::OverloadedError => e expect(e.status_code).to eq(529) end it "e.provider == 'Anthropic (Claude)'" do adapter.chat(messages) rescue Dispatch::Adapter::OverloadedError => e expect(e.provider).to eq("Anthropic (Claude)") end it "e.retry_after == 60" do adapter.chat(messages) rescue Dispatch::Adapter::OverloadedError => e expect(e.retry_after).to eq(60) end it "e.message includes the error message from body" do adapter.chat(messages) rescue Dispatch::Adapter::OverloadedError => e expect(e.message).to include("Overloaded") end end # ── Network connection refused → ConnectionError ────────────────────────── describe "network connection refused" do before { stub_network_error(Errno::ECONNREFUSED) } it "raises ConnectionError" do expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::ConnectionError) end it "e.provider == 'Anthropic (Claude)'" do adapter.chat(messages) rescue Dispatch::Adapter::ConnectionError => e expect(e.provider).to eq("Anthropic (Claude)") end it "e.status_code is nil (no HTTP response)" do adapter.chat(messages) rescue Dispatch::Adapter::ConnectionError => e expect(e.status_code).to be_nil end it "e.message includes the provider name" do adapter.chat(messages) rescue Dispatch::Adapter::ConnectionError => e expect(e.message).to include("Anthropic (Claude)") end end # ── Network timeout → ConnectionError ──────────────────────────────────── describe "network read timeout" do before { stub_network_error(Net::ReadTimeout) } it "raises ConnectionError" do expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::ConnectionError) end it "e.provider == 'Anthropic (Claude)'" do adapter.chat(messages) rescue Dispatch::Adapter::ConnectionError => e expect(e.provider).to eq("Anthropic (Claude)") end end # ── OverloadedError class hierarchy ────────────────────────────────────── describe "OverloadedError class hierarchy" do it "OverloadedError.ancestors includes RateLimitError" do expect(Dispatch::Adapter::OverloadedError.ancestors).to include(Dispatch::Adapter::RateLimitError) end it "OverloadedError.ancestors includes Error" do expect(Dispatch::Adapter::OverloadedError.ancestors).to include(Dispatch::Adapter::Error) end it "OverloadedError.superclass is RateLimitError" do expect(Dispatch::Adapter::OverloadedError.superclass).to eq(Dispatch::Adapter::RateLimitError) end end end