Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## Unreleased

- Added `max_response_bytes` (10 MiB by default, `nil` to disable) and the
`ResponseTooLarge` error. Response bodies had no ceiling: the default
transport buffered whatever the endpoint sent before anything could look at
it, and an error body was then retained on the exception. The default
transport now rejects an oversized `Content-Length` before reading and
otherwise stops mid-stream; bodies from custom transports are measured
before they reach the parser.

## 0.1.0 - 2026-09-18

Provider-neutral release. One `Client`, two providers behind it.
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ RubyDecisionModel::Client.new(
base_url: nil, # overrides the provider base URL
timeout: 5, # open and read timeout in seconds
retry: { max_retries: 2 }, # RetryPolicy or a Hash of overrides
max_response_bytes: 10485760, # ceiling on a response body; nil disables
transport: nil # see Transport
)

Expand Down Expand Up @@ -167,6 +168,16 @@ that accepts `url:`, `headers:`, `body:` and returns
return is still accepted and treated as having no headers, which means no
`Retry-After` support and a nil `request_id`.

## Response size

A response body is read into memory before it can be parsed, so the client caps
it at `max_response_bytes`, 10 MiB by default. The default transport checks
`Content-Length` first and otherwise stops mid-stream once the ceiling is
passed, so an endpoint offering an endless body never gets to fill the process.
A body returned by a custom transport is measured too, before it reaches the
JSON parser or an `ApiError`. Over the limit raises `ResponseTooLarge`, which
is never retried. `nil` disables the check.

## Errors

| Error | Meaning |
Expand All @@ -180,6 +191,7 @@ return is still accepted and treated as having no headers, which means no
| `UnprocessableEntity` | 422 (never retried) |
| `RateLimited` | 429 (retried) |
| `Overloaded` | 529 (retried) |
| `ResponseTooLarge` | Response body passed `max_response_bytes`, carries `#bytes` and `#limit` |
| `InvalidResponse` | Body wasn't JSON, wasn't a Hash, or an answer was malformed |
| `MissingAnswers` | One or more question ids came back missing or wrong-typed, carries `#missing` |

Expand Down
67 changes: 64 additions & 3 deletions lib/ruby_decision_model/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ class Client
RETRYABLE_STATUSES = RetryPolicy::DEFAULT_STATUSES
RETRYABLE_EXCEPTIONS = (RetryPolicy::TIMEOUT_EXCEPTIONS + RetryPolicy::CONNECTION_EXCEPTIONS).freeze

attr_reader :provider, :model, :retry_policy, :timeout
attr_reader :provider, :model, :retry_policy, :timeout, :max_response_bytes

# provider: :open_router, :typesafe, or a Providers::Base instance. When
# nil, api_key: alone selects OpenRouter; otherwise the
Expand All @@ -31,8 +31,16 @@ class Client
# retry: a RetryPolicy or a Hash of overrides.
# random: callable returning a Float in 0...1, used for backoff jitter.
# clock: callable returning monotonic seconds, used for total_timeout.
# The client reads a response into memory before it can parse it, so it
# needs a ceiling. 10 MiB is far more than any decision response: the
# whole request is capped at 64k tokens, and the answers are smaller than
# the questions. It exists to bound a hostile or broken endpoint, not to
# be tuned.
DEFAULT_MAX_RESPONSE_BYTES = 10 * 1024 * 1024

def initialize(provider: nil, api_key: nil, model: nil, base_url: nil, timeout: 5,
transport: nil, sleeper: ->(seconds) { sleep(seconds) }, retry: {},
max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES,
random: -> { rand }, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
@provider = resolve_provider(provider, api_key: api_key, base_url: base_url)
unless @provider.api_key?
Expand All @@ -42,6 +50,7 @@ def initialize(provider: nil, api_key: nil, model: nil, base_url: nil, timeout:

@model = @provider.resolve_model(model)
@timeout = timeout
@max_response_bytes = validate_max_response_bytes(max_response_bytes)
@transport = transport || default_transport
@sleeper = sleeper
@retry_policy = RetryPolicy.from(binding.local_variable_get(:retry))
Expand Down Expand Up @@ -100,9 +109,60 @@ def default_transport
headers.each { |k, v| request[k] = v }
request.body = body

response = http.request(request)
[response.code.to_i, response.body, response.each_header.to_h]
read_capped(http, request)
end
end

# Net::HTTP buffers a whole response before handing it over, so a huge
# body -- from a hostile endpoint, a proxy error page, or an upstream
# having a bad day -- is in memory before anything gets to reject it.
# Read it in chunks instead and stop at the ceiling. Content-Length, when
# the server sends an honest one, ends it before a single chunk arrives.
def read_capped(http, request)
limit = @max_response_bytes
result = nil

http.request(request) do |response|
headers = response.each_header.to_h
status = response.code.to_i
declared = Integer(response["content-length"].to_s, exception: false)
raise_too_large(declared, limit) if limit && declared && declared > limit

body = +""
response.read_body do |chunk|
body << chunk
raise_too_large(body.bytesize, limit) if limit && body.bytesize > limit
end

result = [status, body, headers]
end

result
end

def raise_too_large(bytes, limit)
raise ResponseTooLarge.new(
"response body exceeds max_response_bytes (#{bytes} > #{limit} bytes)",
bytes: bytes, limit: limit
)
end

def validate_max_response_bytes(limit)
return limit if limit.nil?
return limit if limit.is_a?(Integer) && limit.positive?

raise ConfigurationError,
"max_response_bytes must be nil or a positive Integer, got #{limit.inspect}"
end

# A custom transport does its own reading, so the ceiling is checked again
# on whatever it returns. The bytes are already in memory by then, but
# they never reach the JSON parser or an error object that outlives them.
def enforce_response_limit!(response_body)
return if @max_response_bytes.nil? || response_body.nil?

size = response_body.to_s.bytesize
raise_too_large(size, @max_response_bytes) if size > @max_response_bytes
end

def perform_with_retry(url:, headers:, body:)
Expand All @@ -115,6 +175,7 @@ def perform_with_retry(url:, headers:, body:)
status, response_body, response_headers = normalize_transport_result(
@transport.call(url: url, headers: headers, body: body)
)
enforce_response_limit!(response_body)
rescue Error
raise
rescue StandardError => e
Expand Down
12 changes: 12 additions & 0 deletions lib/ruby_decision_model/errors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,18 @@ class RateLimited < ApiError; end

class Overloaded < ApiError; end

# The response was larger than max_response_bytes. Carries how far the
# client got before it gave up, and the limit it was measured against.
class ResponseTooLarge < Error
attr_reader :bytes, :limit

def initialize(message, bytes: nil, limit: nil)
super(message)
@bytes = bytes
@limit = limit
end
end

class InvalidResponse < Error
attr_reader :answers

Expand Down
194 changes: 194 additions & 0 deletions test/response_size_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
# frozen_string_literal: true

require "test_helper"
require "socket"

# A one-shot HTTP server that answers on loopback, so the default Net::HTTP
# transport is exercised for real rather than stubbed.
class TinyServer
attr_reader :port, :bytes_written

def initialize(&responder)
@server = TCPServer.new("127.0.0.1", 0)
@port = @server.addr[1]
@responder = responder
@bytes_written = 0
@thread = Thread.new { serve }
end

def close
@thread.kill
@server.close
rescue IOError
nil
end

private

def serve
loop do
socket = @server.accept
read_request(socket)
@responder.call(socket, self)
socket.close
rescue StandardError
nil
end
end

def read_request(socket)
length = 0
while (line = socket.gets)
break if line == "\r\n"

length = Regexp.last_match(1).to_i if line =~ /\AContent-Length:\s*(\d+)/i
end
socket.read(length) if length.positive?
end

public

def write(socket, data)
socket.write(data)
@bytes_written += data.bytesize
end
end

class ResponseSizeTest < Minitest::Test
def questions
{ "urgent" => RubyDecisionModel::Questions.noul("Is this urgent?") }
end

def success_body
JSON.generate(
"answers" => { "urgent" => { "type" => "noul", "noul" => 0.5 } },
"usage" => { "input_tokens" => 1, "output_tokens" => 1 }
)
end

def build_client(transport, **options)
RubyDecisionModel::Client.new(
**{ api_key: "test-key", transport: transport, sleeper: no_sleep }.merge(options)
)
end

# --- configuration ---

def test_default_limit_is_ten_mebibytes
assert_equal 10 * 1024 * 1024, build_client(FakeTransport.new([])).max_response_bytes
end

def test_limit_must_be_nil_or_a_positive_integer
[0, -1, 1.5, "10"].each do |bad|
assert_raises(RubyDecisionModel::ConfigurationError, "expected #{bad.inspect} to be rejected") do
build_client(FakeTransport.new([]), max_response_bytes: bad)
end
end
assert_nil build_client(FakeTransport.new([]), max_response_bytes: nil).max_response_bytes
end

# --- custom transports are checked too ---

def test_an_oversized_body_from_a_custom_transport_is_rejected_before_parsing
transport = FakeTransport.new([[200, "x" * 100]])
client = build_client(transport, max_response_bytes: 50)

error = assert_raises(RubyDecisionModel::ResponseTooLarge) { client.ask(state: {}, questions: questions) }

assert_equal 100, error.bytes
assert_equal 50, error.limit
end

def test_an_oversized_error_body_is_rejected_too
# A 500 body is retained on the exception, so it needs the same ceiling.
transport = FakeTransport.new([[500, "x" * 100]])
client = build_client(transport, max_response_bytes: 50, retry: { max_retries: 0 })

assert_raises(RubyDecisionModel::ResponseTooLarge) { client.ask(state: {}, questions: questions) }
end

def test_an_oversized_response_is_not_retried
transport = FakeTransport.new([[200, "x" * 100]])
client = build_client(transport, max_response_bytes: 50)

assert_raises(RubyDecisionModel::ResponseTooLarge) { client.ask(state: {}, questions: questions) }
assert_equal 1, transport.calls.length
end

def test_a_body_at_the_limit_is_accepted
body = success_body
transport = FakeTransport.new([[200, body]])
client = build_client(transport, max_response_bytes: body.bytesize)

assert_in_delta 0.5, client.ask(state: {}, questions: questions)["urgent"].noul
end

def test_a_nil_limit_disables_the_check
transport = FakeTransport.new([[200, success_body]])
client = build_client(transport, max_response_bytes: nil)

assert_in_delta 0.5, client.ask(state: {}, questions: questions)["urgent"].noul
end

# --- the default transport stops reading rather than buffering it all ---

def test_the_default_transport_stops_reading_an_oversized_chunked_body
# No Content-Length: the only way to know the size is to read it, which is
# the case the cap has to handle by stopping mid-stream.
server = TinyServer.new do |socket, srv|
srv.write(socket, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n" \
"Transfer-Encoding: chunked\r\n\r\n")
600.times { srv.write(socket, "400\r\n#{'x' * 1024}\r\n") }
srv.write(socket, "0\r\n\r\n")
end

client = RubyDecisionModel::Client.new(
api_key: "k", base_url: "http://127.0.0.1:#{server.port}", max_response_bytes: 64 * 1024,
sleeper: no_sleep, retry: { max_retries: 0 }
)

error = assert_raises(RubyDecisionModel::ResponseTooLarge) { client.ask(state: {}, questions: questions) }

assert_operator error.bytes, :>, 64 * 1024
# 600 KiB was on offer; the client gave up long before it had all of it.
assert_operator server.bytes_written, :<, 600 * 1024
ensure
server&.close
end

def test_the_default_transport_rejects_an_oversized_content_length_before_reading
server = TinyServer.new do |socket, srv|
srv.write(socket, "HTTP/1.1 200 OK\r\nContent-Length: 1048576\r\n\r\n")
srv.write(socket, "x" * 1024)
end

client = RubyDecisionModel::Client.new(
api_key: "k", base_url: "http://127.0.0.1:#{server.port}", max_response_bytes: 1024,
sleeper: no_sleep, retry: { max_retries: 0 }
)

error = assert_raises(RubyDecisionModel::ResponseTooLarge) { client.ask(state: {}, questions: questions) }

assert_equal 1_048_576, error.bytes
ensure
server&.close
end

def test_the_default_transport_returns_a_body_within_the_limit
body = success_body
server = TinyServer.new do |socket, srv|
srv.write(socket, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n" \
"Content-Length: #{body.bytesize}\r\n\r\n#{body}")
end

client = RubyDecisionModel::Client.new(
api_key: "k", base_url: "http://127.0.0.1:#{server.port}", sleeper: no_sleep
)

response = client.ask(state: {}, questions: questions)

assert_in_delta 0.5, response["urgent"].noul
ensure
server&.close
end
end