# frozen_string_literal: true require "webmock/rspec" RSpec.describe Dispatch::Adapter::Claude::HttpClient do let(:base_url) { "https://api.anthropic.com" } let(:test_headers) { { "Content-Type" => "application/json", "Accept" => "application/json" } } let(:headers_proc) do lambda { |stream: false| _ = stream test_headers } end subject(:client) { described_class.new(base_url: base_url, headers_proc: headers_proc) } before { WebMock.disable_net_connect! } after { WebMock.reset! } # ── post_json ───────────────────────────────────────────────────────────── describe "#post_json" do let(:path) { "/v1/messages" } let(:request_body) { { model: "claude-sonnet-4-6", messages: [] } } let(:response_body) do { "id" => "msg_01", "type" => "message", "content" => [], "role" => "assistant" } end context "on a 200 response" do before do stub_request(:post, "#{base_url}#{path}") .to_return(status: 200, body: JSON.generate(response_body), headers: { "Content-Type" => "application/json" }) end it "returns the parsed JSON response body" do result = client.post_json(path, request_body) expect(result).to eq(response_body) end it "sends the body serialized as JSON" do client.post_json(path, request_body) expect(WebMock).to have_requested(:post, "#{base_url}#{path}") .with(body: JSON.generate(request_body)) end it "sends the headers from the headers_proc" do client.post_json(path, request_body) expect(WebMock).to have_requested(:post, "#{base_url}#{path}") .with(headers: { "Content-Type" => "application/json" }) end end context "on a 401 response" do before do stub_request(:post, "#{base_url}#{path}") .to_return(status: 401, body: JSON.generate({ "error" => { "message" => "Unauthorized" } }), headers: { "Content-Type" => "application/json" }) end it "raises AuthenticationError" do expect { client.post_json(path, request_body) } .to raise_error(Dispatch::Adapter::AuthenticationError) end it "includes the error message" do expect { client.post_json(path, request_body) } .to raise_error(Dispatch::Adapter::AuthenticationError, /Unauthorized/) end end context "on a 429 response" do before do stub_request(:post, "#{base_url}#{path}") .to_return(status: 429, body: JSON.generate({ "error" => { "message" => "Rate limited" } }), headers: { "Content-Type" => "application/json", "Retry-After" => "30" }) end it "raises RateLimitError" do expect { client.post_json(path, request_body) } .to raise_error(Dispatch::Adapter::RateLimitError) end end context "on a 500 response" do before do stub_request(:post, "#{base_url}#{path}") .to_return(status: 500, body: JSON.generate({ "error" => { "message" => "Internal error" } }), headers: { "Content-Type" => "application/json" }) end it "raises ServerError" do expect { client.post_json(path, request_body) } .to raise_error(Dispatch::Adapter::ServerError) end end context "with malformed JSON response" do before do stub_request(:post, "#{base_url}#{path}") .to_return(status: 200, body: "not json at all", headers: { "Content-Type" => "text/plain" }) end it "raises RequestError" do expect { client.post_json(path, request_body) } .to raise_error(Dispatch::Adapter::RequestError, /Failed to parse JSON/) end end end # ── get_json ────────────────────────────────────────────────────────────── describe "#get_json" do let(:path) { "/v1/models" } let(:response_body) { { "data" => [{ "id" => "claude-sonnet-4-6" }] } } context "on a 200 response" do before do stub_request(:get, "#{base_url}#{path}") .to_return(status: 200, body: JSON.generate(response_body), headers: { "Content-Type" => "application/json" }) end it "returns the parsed JSON response body" do result = client.get_json(path) expect(result).to eq(response_body) end it "uses a GET request (not POST)" do client.get_json(path) expect(WebMock).to have_requested(:get, "#{base_url}#{path}") expect(WebMock).not_to have_requested(:post, "#{base_url}#{path}") end end context "on a 401 response" do before do stub_request(:get, "#{base_url}#{path}") .to_return(status: 401, body: JSON.generate({ "error" => { "message" => "Unauthorized" } }), headers: { "Content-Type" => "application/json" }) end it "raises AuthenticationError" do expect { client.get_json(path) } .to raise_error(Dispatch::Adapter::AuthenticationError) end end end # ── stream ──────────────────────────────────────────────────────────────── describe "#stream" do let(:path) { "/v1/messages" } let(:request_body) { { model: "claude-sonnet-4-6", stream: true } } let(:sse_body) { "data: {\"type\":\"message_start\"}\n\ndata: [DONE]\n\n" } context "on a 200 SSE response" do before do stub_request(:post, "#{base_url}#{path}") .to_return(status: 200, body: sse_body, headers: { "Content-Type" => "text/event-stream" }) end it "yields a Net::HTTPResponse object" do yielded = nil client.stream(path, request_body) { |resp| yielded = resp } expect(yielded).to be_a(Net::HTTPResponse) end it "yields a response that responds to read_body" do client.stream(path, request_body) do |resp| expect(resp).to respond_to(:read_body) end end it "yields the response before it raises" do was_yielded = false client.stream(path, request_body) { |_resp| was_yielded = true } expect(was_yielded).to be true end it "sends a POST request with Accept: text/event-stream" do stream_headers = { "Content-Type" => "application/json", "Accept" => "text/event-stream" } stream_headers_proc = ->(stream: false) { stream ? stream_headers : test_headers } stream_client = described_class.new(base_url: base_url, headers_proc: stream_headers_proc) stream_client.stream(path, request_body) { |_r| nil } expect(WebMock).to have_requested(:post, "#{base_url}#{path}") .with(headers: { "Accept" => "text/event-stream" }) end end context "when called without a block" do it "raises ArgumentError" do expect { client.stream(path, request_body) } .to raise_error(ArgumentError, /block/) end end context "on a 401 SSE response" do before do stub_request(:post, "#{base_url}#{path}") .to_return(status: 401, body: JSON.generate({ "error" => { "message" => "Unauthorized" } }), headers: { "Content-Type" => "application/json" }) end it "raises AuthenticationError after yielding the response" do yielded = false expect do client.stream(path, request_body) { |_r| yielded = true } end.to raise_error(Dispatch::Adapter::AuthenticationError) expect(yielded).to be true end end end # ── Connection failures ─────────────────────────────────────────────────── describe "connection failures" do let(:path) { "/v1/messages" } let(:request_body) { {} } { "Errno::ECONNREFUSED" => Errno::ECONNREFUSED, "Errno::EHOSTUNREACH" => Errno::EHOSTUNREACH, "Errno::ETIMEDOUT" => Errno::ETIMEDOUT, "SocketError" => SocketError }.each do |error_name, error_class| context "when #{error_name} is raised" do before do stub_request(:post, "#{base_url}#{path}").to_raise(error_class) end it "raises ConnectionError" do expect { client.post_json(path, request_body) } .to raise_error(Dispatch::Adapter::ConnectionError) end it "includes the provider name in the error message" do expect { client.post_json(path, request_body) } .to raise_error(Dispatch::Adapter::ConnectionError, /Anthropic \(Claude\)/) end it "sets the provider attribute on the error" do client.post_json(path, request_body) rescue Dispatch::Adapter::ConnectionError => e expect(e.provider).to eq("Anthropic (Claude)") end end end context "when Net::ReadTimeout is raised" do before do stub_request(:post, "#{base_url}#{path}").to_raise(Net::ReadTimeout) end it "raises ConnectionError" do expect { client.post_json(path, request_body) } .to raise_error(Dispatch::Adapter::ConnectionError) end end context "when SocketError is raised on get_json" do before do stub_request(:get, "#{base_url}/v1/models").to_raise(SocketError) end it "raises ConnectionError" do expect { client.get_json("/v1/models") } .to raise_error(Dispatch::Adapter::ConnectionError) end end end # ── Path normalization ──────────────────────────────────────────────────── describe "path normalization" do it "adds a leading slash if the path does not have one" do stub_request(:get, "#{base_url}/v1/models") .to_return(status: 200, body: "{}", headers: {}) # Should not raise — path is normalized correctly expect { client.get_json("v1/models") }.not_to raise_error end it "preserves an existing leading slash" do stub_request(:get, "#{base_url}/v1/models") .to_return(status: 200, body: "{}", headers: {}) expect { client.get_json("/v1/models") }.not_to raise_error end end # ── base_url trailing slash tolerance ───────────────────────────────────── describe "base_url with trailing slash" do subject(:client_trailing) do described_class.new(base_url: "https://api.anthropic.com/", headers_proc: headers_proc) end it "still connects to the correct host" do stub_request(:get, "https://api.anthropic.com/v1/models") .to_return(status: 200, body: "{}", headers: {}) expect { client_trailing.get_json("/v1/models") }.not_to raise_error end end # ── Timeout constants ───────────────────────────────────────────────────── describe "timeout constants" do it "has OPEN_TIMEOUT of 30" do expect(described_class::OPEN_TIMEOUT).to eq(30) end it "has READ_TIMEOUT of 120" do expect(described_class::READ_TIMEOUT).to eq(120) end it "has STREAM_TIMEOUT of 300" do expect(described_class::STREAM_TIMEOUT).to eq(300) end end end