# frozen_string_literal: true module Dispatch module Adapter class Claude < Base # Thin Net::HTTP wrapper for the Anthropic API. # # Handles TLS, timeouts, connection-error normalization, and routes # non-2xx responses through ClaudeErrors.handle_response!. # # Two request methods are provided: # post_json(path, body) — buffers the entire response body, returns Hash # get_json(path) — buffers the entire response body, returns Hash # stream(path, body) — yields the Net::HTTPResponse for streaming # (SSE/chunked), still checks status afterwards class HttpClient # Timeout constants (in seconds) OPEN_TIMEOUT = 30 READ_TIMEOUT = 120 STREAM_TIMEOUT = 300 # Network-level errors that are normalized to ConnectionError NETWORK_ERRORS = [ Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ETIMEDOUT, Errno::ECONNRESET, Errno::ENETUNREACH, Net::OpenTimeout, Net::ReadTimeout, SocketError, IOError, OpenSSL::SSL::SSLError ].freeze # @param base_url [String] e.g. "https://api.anthropic.com" # @param headers_proc [Proc] called with no args each request to # produce a fresh Hash of headers. def initialize(base_url:, headers_proc:) @uri = URI.parse(base_url.to_s.chomp("/")) @headers_proc = headers_proc end # Perform a POST, return the parsed JSON response body as a Hash. # # @param path [String] e.g. "/v1/messages" # @param body [Hash] serialized as JSON # @param on_response [Proc,nil] called with the raw Net::HTTPResponse # before the body is parsed (for header capture) # @return [Hash] def post_json(path, body, on_response: nil) headers = build_headers(stream: false) request = build_post_request(path, body, headers) response = make_request(request, read_timeout: READ_TIMEOUT) on_response&.call(response) ClaudeErrors.handle_response!(response) parse_json_body(response.body) end # Perform a GET, return the parsed JSON response body as a Hash. # # @param path [String] e.g. "/v1/models" # @param on_response [Proc,nil] called with the raw Net::HTTPResponse # @return [Hash] def get_json(path, on_response: nil) headers = build_headers(stream: false) request = build_get_request(path, headers) response = make_request(request, read_timeout: READ_TIMEOUT) on_response&.call(response) ClaudeErrors.handle_response!(response) parse_json_body(response.body) end # Perform a streaming POST (SSE / chunked transfer). The # `Net::HTTPResponse` is yielded to the caller BEFORE error-checking # so that the caller can consume the SSE stream inline. After the # block returns, status is still checked (non-2xx → exception). # # @param path [String] # @param body [Hash] # @yieldparam response [Net::HTTPResponse] # @return [void] def stream(path, body, &block) raise ArgumentError, "stream requires a block" unless block headers = build_headers(stream: true) request = build_post_request(path, body, headers) do_stream(request, &block) end private # ── Request builders ───────────────────────────────────────────────── def build_post_request(path, body, headers) req = Net::HTTP::Post.new(full_path(path)) apply_headers!(req, headers) req.body = JSON.generate(body) req end def build_get_request(path, headers) req = Net::HTTP::Get.new(full_path(path)) apply_headers!(req, headers) req end def full_path(path) path.to_s.start_with?("/") ? path.to_s : "/#{path}" end def apply_headers!(request, headers) headers.each { |k, v| request[k] = v } end # ── Connection helpers ─────────────────────────────────────────────── # Execute a buffered request (full response read into memory). def make_request(request, read_timeout:) with_connection(read_timeout: read_timeout) do |http| http.request(request) end end # Execute a streaming request. The response is yielded with # `read_body` available; the block must consume the body before # the connection is torn down. def do_stream(request, &block) with_connection(read_timeout: STREAM_TIMEOUT) do |http| http.request(request) do |response| block.call(response) ClaudeErrors.handle_response!(response) end end end # Open an HTTPS connection, yield it, and normalize network errors. def with_connection(read_timeout:, &) use_ssl = @uri.scheme == "https" hostname = @uri.hostname port = @uri.port || (use_ssl ? 443 : 80) http = Net::HTTP.new(hostname, port) http.use_ssl = use_ssl http.open_timeout = OPEN_TIMEOUT http.read_timeout = read_timeout http.verify_mode = OpenSSL::SSL::VERIFY_PEER if use_ssl http.start(&) rescue *NETWORK_ERRORS => e raise ConnectionError.new( "#{ClaudeErrors::PROVIDER}: #{e.class}: #{e.message}", provider: ClaudeErrors::PROVIDER ) end # ── Header / body helpers ──────────────────────────────────────────── def build_headers(stream:) @headers_proc.call(stream: stream) end def parse_json_body(body) JSON.parse(body.to_s) rescue JSON::ParserError => e raise RequestError.new( "Failed to parse JSON response: #{e.message}", provider: ClaudeErrors::PROVIDER ) end end end end end