1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
|
# 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
|