Skip to content
Closed
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
38 changes: 35 additions & 3 deletions lib/ruby_llm/streaming.rb
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,13 @@ def error_chunk?(chunk)
def handle_error_chunk(chunk, env)
error_data = chunk.split("\n")[1].delete_prefix('data: ')
status, _message = parse_streaming_error(error_data)
error_response = env.merge(body: JSON.parse(error_data), status: status)

error_response = create_error_response(
body: JSON.parse(error_data),
status: status,
env: env
)

ErrorMiddleware.parse_error(provider: self, response: error_response)
rescue JSON::ParserError => e
RubyLLM.logger.debug "Failed to parse error chunk: #{e.message}"
Expand All @@ -97,7 +103,13 @@ def handle_error_chunk(chunk, env)
def handle_failed_response(chunk, buffer, env)
buffer << chunk
error_data = JSON.parse(buffer)
error_response = env.merge(body: error_data)

error_response = create_error_response(
body: error_data,
status: env&.status || 500,
env: env
)

ErrorMiddleware.parse_error(provider: self, response: error_response)
rescue JSON::ParserError
RubyLLM.logger.debug "Accumulating error chunk: #{chunk}"
Expand All @@ -122,10 +134,30 @@ def handle_data(data)

def handle_error_event(data, env)
status, _message = parse_streaming_error(data)
error_response = env.merge(body: JSON.parse(data), status: status)

error_response = create_error_response(
body: JSON.parse(data),
status: status,
env: env
)

ErrorMiddleware.parse_error(provider: self, response: error_response)
rescue JSON::ParserError => e
RubyLLM.logger.debug "Failed to parse error event: #{e.message}"
end

# Create a response-like object that mimics Faraday::Response interface
def create_error_response(body:, status:, env:)
# Simple struct to mimic Faraday::Response interface
ErrorResponse.new(
body: body.is_a?(String) ? body : JSON.generate(body),
status: status,
headers: env&.response_headers || {},
env: env || {}
)
end

# Simple struct to hold error response data
ErrorResponse = Struct.new(:body, :status, :headers, :env, keyword_init: true) # rubocop:disable Lint/UselessConstantScoping
end
end
100 changes: 100 additions & 0 deletions spec/ruby_llm/error_handling_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,104 @@
chat.ask('Hello')
end.to raise_error(RubyLLM::UnauthorizedError)
end

describe 'Provider#parse_error' do
let(:test_provider) do
Class.new do
extend RubyLLM::Provider

def self.slug
'test_provider'
end
end
end

it 'parses error from response objects' do
response = instance_double(Faraday::Response, body: '{"error": {"message": "API key invalid"}}')
expect(test_provider.parse_error(response)).to eq('API key invalid')
end

it 'handles empty body' do
response = instance_double(Faraday::Response, body: '')
expect(test_provider.parse_error(response)).to be_nil
end

it 'handles malformed JSON' do
response = instance_double(Faraday::Response, body: '{invalid json}')
expect(test_provider.parse_error(response)).to eq('{invalid json}')
end
end

describe 'Streaming error handling' do
let(:test_provider) do
Class.new do
extend RubyLLM::Provider
extend RubyLLM::Streaming

def self.slug
'test_provider'
end

def self.parse_streaming_error(error_data)
data = begin
JSON.parse(error_data)
rescue StandardError
{}
end
status = case data.dig('error', 'type')
when 'authentication_error' then 401
when 'rate_limit_error' then 429
else 500
end
[status, data.dig('error', 'message')]
end
end
end

describe '#handle_error_chunk' do
it 'handles error chunks with nil env' do
chunk = "event: error\ndata: {\"error\": {\"type\": \"authentication_error\", " \
'"message": "Invalid API key"}}'

expect do
test_provider.send(:handle_error_chunk, chunk, nil)
end.to raise_error(RubyLLM::UnauthorizedError, /Invalid API key/)
end

it 'handles error chunks with env object' do # rubocop:disable RSpec/ExampleLength
chunk = "event: error\ndata: {\"error\": {\"type\": \"rate_limit_error\", " \
'"message": "Rate limit exceeded"}}'
env = double('env', status: 429, response_headers: { 'content-type' => 'application/json' }) # rubocop:disable RSpec/VerifiedDoubles

expect do
test_provider.send(:handle_error_chunk, chunk, env)
end.to raise_error(RubyLLM::RateLimitError, /Rate limit exceeded/)
end
end

describe '#handle_failed_response' do
it 'handles failed responses with nil env' do
buffer = String.new
chunk = '{"error": {"type": "authentication_error", "message": "API key expired"}}'

expect do
test_provider.send(:handle_failed_response, chunk, buffer, nil)
end.to raise_error(RubyLLM::ServerError)
end
end

describe '#create_error_response' do
it 'creates a proper response object' do # rubocop:disable RSpec/ExampleLength,RSpec/MultipleExpectations
response = test_provider.send(:create_error_response,
body: { 'error' => 'test error' },
status: 400,
env: nil)

expect(response).to respond_to(:body)
expect(response).to respond_to(:status)
expect(response.body).to eq('{"error":"test error"}')
expect(response.status).to eq(400)
end
end
end
end