Class: LittleGhost::Support::HTTPClient

Inherits:
Object
  • Object
show all
Defined in:
lib/little_ghost/support/http_client.rb

Overview

HTTPClient gives integrations a shared, bounded streaming HTTP layer. It applies cancellation, deadlines, timeouts, and response-size limits while yielding response chunks as they arrive.

Security and trust

HTTPS is required by default. Enabling allow_insecure_http can expose API keys and model content in transit; use it only with a trusted local development endpoint.

Constant Summary collapse

DEFAULT_MAX_RESPONSE_BYTES =

Default upper bound for a complete provider response (50 MiB).

50 * 1024 * 1024
DEFAULT_MAX_ERROR_BODY_BYTES =

:nodoc:

4 * 1024
TRANSIENT_NETWORK_ERRORS =
[
  Net::OpenTimeout,
  Net::ReadTimeout,
  Net::WriteTimeout,
  EOFError,
  SocketError,
  SystemCallError,
  IOError,
  OpenSSL::SSL::SSLError,
  Net::ProtocolError,
  Net::HTTPBadResponse,
  Net::HTTPHeaderSyntaxError
].freeze

Instance Method Summary collapse

Constructor Details

#initialize(base_url: nil, open_timeout: 10, read_timeout: 120, allow_insecure_http: false, max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES, max_error_body_bytes: DEFAULT_MAX_ERROR_BODY_BYTES) ⇒ HTTPClient

Configures base_url with connection, read, and response size limits.



38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
# File 'lib/little_ghost/support/http_client.rb', line 38

def initialize(
  base_url: nil,
  open_timeout: 10,
  read_timeout: 120,
  allow_insecure_http: false,
  max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES,
  max_error_body_bytes: DEFAULT_MAX_ERROR_BODY_BYTES
)
  if base_url
    @base_url = URI(base_url.end_with?("/") ? base_url : "#{base_url}/")
    validate_uri!(@base_url, allow_insecure_http:)
  end

  @open_timeout = open_timeout
  @read_timeout = read_timeout
  @allow_insecure_http = allow_insecure_http
  @max_response_bytes = positive_integer(max_response_bytes, :max_response_bytes)
  @max_error_body_bytes = positive_integer(max_error_body_bytes, :max_error_body_bytes)
end

Instance Method Details

#each_chunk(uri:, method: :get, headers: {}, body: nil, deadline: nil, cancellation_token: nil, allow_insecure_http: false, label: "HTTP request") ⇒ Object

Executes a bounded request and yields response chunks as they arrive.



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
# File 'lib/little_ghost/support/http_client.rb', line 98

def each_chunk(uri:, method: :get, headers: {}, body: nil, deadline: nil,
  cancellation_token: nil, allow_insecure_http: false, label: "HTTP request")
  unless block_given?
    return enum_for(
      __method__, uri:, method:, headers:, body:, deadline:, cancellation_token:, allow_insecure_http:, label:
    )
  end

  uri = URI(uri.to_s)
  validate_uri!(uri, allow_insecure_http:)
  check_control!(cancellation_token, deadline)
  request_class = {get: Net::HTTP::Get, post: Net::HTTP::Post, put: Net::HTTP::Put}.fetch(method.to_sym) do
    raise ArgumentError, "unsupported HTTP method: #{method}"
  end
  request = request_class.new(uri)
  headers.each { |name, value| request[name] = value unless value.to_s.empty? }
  request.body = body if body
  http = Net::HTTP.new(uri.host, uri.port)
  http.use_ssl = uri.scheme == "https"
  http.open_timeout = remaining_timeout(deadline, @open_timeout)
  http.read_timeout = remaining_timeout(deadline, @read_timeout)
  http.write_timeout = remaining_timeout(deadline, @read_timeout)
  http.request(request) do |response|
    unless response.is_a?(Net::HTTPSuccess)
      response_body = read_limited(response, @max_error_body_bytes)
      raise Providers::HTTPError.new(
        "#{label} failed with HTTP #{response.code}",
        status: response.code.to_i,
        body: response_body
      )
    end

    bytes_read = 0
    response.read_body do |chunk|
      check_control!(cancellation_token, deadline)
      bytes_read += chunk.bytesize
      raise ProtocolError, "#{label} response exceeded #{@max_response_bytes} bytes" if bytes_read > @max_response_bytes

      yield chunk
    end
  end
rescue *TRANSIENT_NETWORK_ERRORS => error
  raise Providers::HTTPError, "#{label} failed (#{error.class})"
end

#request(uri:, method: :get, headers: {}, body: nil, allow_insecure_http: false, cancellation_token: nil, deadline: nil) ⇒ Object

Executes a bounded request and returns the complete response body.



82
83
84
85
86
87
88
89
90
91
92
93
94
95
# File 'lib/little_ghost/support/http_client.rb', line 82

def request(uri:, method: :get, headers: {}, body: nil, allow_insecure_http: false,
  cancellation_token: nil, deadline: nil)
  response_body = +""
  each_chunk(
    uri:,
    method:,
    headers:,
    body:,
    cancellation_token:,
    deadline: deadline || Time.now + @read_timeout,
    allow_insecure_http:
  ) { |chunk| response_body << chunk }
  response_body
end

#stream(path:, headers:, body:, cancellation_token:, deadline: nil) ⇒ Object

Posts body and yields response chunks until completion.

Cancellation and deadline interrupt the request. Without a block, this method returns an Enumerator.

Raises:



62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
# File 'lib/little_ghost/support/http_client.rb', line 62

def stream(path:, headers:, body:, cancellation_token:, deadline: nil)
  return enum_for(__method__, path:, headers:, body:, cancellation_token:, deadline:) unless block_given?
  raise ConfigurationError, "HTTP client requires base_url for streaming" unless @base_url

  stream = Support::InterruptibleStream.new(cancellation_token:, deadline:) do |emit|
    uri = URI.join(@base_url.to_s, path.sub(%r{\A/}, ""))
    each_chunk(
      uri:,
      method: :post,
      headers:,
      body:,
      deadline:,
      allow_insecure_http: @allow_insecure_http,
      label: "Provider"
    ) { |chunk| emit.call(chunk) }
  end
  stream.each { |chunk| yield chunk }
end