Skip to content

Commit 735e36b

Browse files
dansingermancrmne
andauthored
Added proper handling of streaming error responses across both Faraday V1 and V2 (#273)
## What this does When used within our app, streaming error responses were throwing an error and not being properly handled ``` worker | D, [2025-07-03T18:49:52.221013 #81269] DEBUG -- RubyLLM: Received chunk: event: error worker | data: {"type":"error","error":{"details":null,"type":"overloaded_error","message":"Overloaded"} } worker | worker | worker | 2025-07-03 18:49:52.233610 E [81269:sidekiq.default/processor chat_agent.rb:42] {jid: 7382519287f08cfa7cd1e4e4, queue: default} Rails -- Error in ChatAgent#send_with_streaming: NoMethodError - undefined method `merge' for nil:NilClass worker | worker | error_response = env.merge(body: JSON.parse(error_data), status: status) worker | ^^^^^^ worker | 2025-07-03 18:49:52.233852 E [81269:sidekiq.default/processor chat_agent.rb:43] {jid: 7382519287f08cfa7cd1e4e4, queue: default} Rails -- Backtrace: /Users/dansingerman/.rbenv/versions/3.1.6/lib/ruby/gems/3.1.0/gems/ruby_llm-1.3.1/lib/ruby_llm/streaming.rb:91:in `handle_error_chunk' worker | /Users/dansingerman/.rbenv/versions/3.1.6/lib/ruby/gems/3.1.0/gems/ruby_llm-1.3.1/lib/ruby_llm/streaming.rb:62:in `process_stream_chunk' worker | /Users/dansingerman/.rbenv/versions/3.1.6/lib/ruby/gems/3.1.0/gems/ruby_llm-1.3.1/lib/ruby_llm/streaming.rb:70:in `block in legacy_stream_processor' worker | /Users/dansingerman/.rbenv/versions/3.1.6/lib/ruby/gems/3.1.0/gems/faraday-net_http-1.0.1/lib/faraday/adapter/net_http.rb:113:in `block in perform_request' worker | /Users/dansingerman/.rbenv/versions/3.1.6/lib/ruby/gems/3.1.0/gems/net-protocol-0.2.2/lib/net/protocol.rb:535:in `call_block' worker | /Users/dansingerman/.rbenv/versions/3.1.6/lib/ruby/gems/3.1.0/gems/net-protocol-0.2.2/lib/net/protocol.rb:526:in `<<' worker | /Users/dansingerman/.rbenv/versions/3.1.6/lib/ruby/gems/3.1.0/gems/net-protocol-0.2.2/lib/net/protocol.rb ``` It looks like the [introduction of support for Faraday V1 ](#173 this error, as the error handling relies on an `env` that is no longer passed. This should provide a fix for both V1 and V2. One thing to note, I had to manually construct the VCR cassettes, I'm not sure of a better way to test an intermittent error response. I have also only written the tests against `anthropic/claude-3-5-haiku-20241022` - it's possible other models with a different error format may still not be properly handled, but even in that case it won't error for the reasons fixed here. ## Type of change - [x] Bug fix - [ ] New feature - [ ] Breaking change - [ ] Documentation - [ ] Performance improvement ## Scope check - [x] I read the [Contributing Guide](https://github.com/crmne/ruby_llm/blob/main/CONTRIBUTING.md) - [x] This aligns with RubyLLM's focus on **LLM communication** - [x] This isn't application-specific logic that belongs in user code - [x] This benefits most users, not just my specific use case ## Quality check - [x] I ran `overcommit --install` and all hooks pass - [x] I tested my changes thoroughly - [x] I updated documentation if needed - [x] I didn't modify auto-generated files manually (`models.json`, `aliases.json`) ## API changes - [ ] Breaking change - [ ] New public methods/classes - [ ] Changed method signatures - [x] No API changes ## Related issues --------- Co-authored-by: Carmine Paolino <[email protected]>
1 parent a9a1446 commit 735e36b

File tree

5 files changed

+229
-5
lines changed

5 files changed

+229
-5
lines changed

lib/ruby_llm/providers/openai/streaming.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,20 @@ def build_chunk(data)
2121
output_tokens: data.dig('usage', 'completion_tokens')
2222
)
2323
end
24+
25+
def parse_streaming_error(data)
26+
error_data = JSON.parse(data)
27+
return unless error_data['error']
28+
29+
case error_data.dig('error', 'type')
30+
when 'server_error'
31+
[500, error_data['error']['message']]
32+
when 'rate_limit_exceeded', 'insufficient_quota'
33+
[429, error_data['error']['message']]
34+
else
35+
[400, error_data['error']['message']]
36+
end
37+
end
2438
end
2539
end
2640
end

lib/ruby_llm/streaming.rb

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,13 +55,13 @@ def create_stream_processor(parser, buffer, &)
5555
end
5656
end
5757

58-
def process_stream_chunk(chunk, parser, _env, &)
58+
def process_stream_chunk(chunk, parser, env, &)
5959
RubyLLM.logger.debug "Received chunk: #{chunk}"
6060

6161
if error_chunk?(chunk)
62-
handle_error_chunk(chunk, nil)
62+
handle_error_chunk(chunk, env)
6363
else
64-
yield handle_sse(chunk, parser, nil, &)
64+
yield handle_sse(chunk, parser, env, &)
6565
end
6666
end
6767

@@ -88,7 +88,16 @@ def error_chunk?(chunk)
8888
def handle_error_chunk(chunk, env)
8989
error_data = chunk.split("\n")[1].delete_prefix('data: ')
9090
status, _message = parse_streaming_error(error_data)
91-
error_response = env.merge(body: JSON.parse(error_data), status: status)
91+
parsed_data = JSON.parse(error_data)
92+
93+
# Create a response-like object that works for both Faraday v1 and v2
94+
error_response = if env
95+
env.merge(body: parsed_data, status: status)
96+
else
97+
# For Faraday v1, create a simple object that responds to .status and .body
98+
Struct.new(:body, :status).new(parsed_data, status)
99+
end
100+
92101
ErrorMiddleware.parse_error(provider: self, response: error_response)
93102
rescue JSON::ParserError => e
94103
RubyLLM.logger.debug "Failed to parse error chunk: #{e.message}"
@@ -122,7 +131,16 @@ def handle_data(data)
122131

123132
def handle_error_event(data, env)
124133
status, _message = parse_streaming_error(data)
125-
error_response = env.merge(body: JSON.parse(data), status: status)
134+
parsed_data = JSON.parse(data)
135+
136+
# Create a response-like object that works for both Faraday v1 and v2
137+
error_response = if env
138+
env.merge(body: parsed_data, status: status)
139+
else
140+
# For Faraday v1, create a simple object that responds to .status and .body
141+
Struct.new(:body, :status).new(parsed_data, status)
142+
end
143+
126144
ErrorMiddleware.parse_error(provider: self, response: error_response)
127145
rescue JSON::ParserError => e
128146
RubyLLM.logger.debug "Failed to parse error event: #{e.message}"

spec/ruby_llm/chat_streaming_spec.rb

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
RSpec.describe RubyLLM::Chat do
66
include_context 'with configured RubyLLM'
7+
include StreamingErrorHelpers
78

89
describe 'streaming responses' do
910
CHAT_MODELS.each do |model_info|
@@ -47,4 +48,83 @@
4748
end
4849
end
4950
end
51+
52+
describe 'Error handling' do
53+
CHAT_MODELS.each do |model_info|
54+
model = model_info[:model]
55+
provider = model_info[:provider]
56+
57+
context "with #{provider}/#{model}" do
58+
let(:chat) { RubyLLM.chat(model: model, provider: provider) }
59+
60+
describe 'Faraday version 1' do # rubocop:disable RSpec/NestedGroups
61+
before do
62+
stub_const('Faraday::VERSION', '1.10.0')
63+
end
64+
65+
it "#{provider}/#{model} supports handling streaming error chunks" do # rubocop:disable RSpec/ExampleLength
66+
skip('Error handling not implemented yet') unless error_handling_supported?(provider)
67+
68+
stub_error_response(provider, :chunk)
69+
70+
chunks = []
71+
72+
expect do
73+
chat.ask('Count from 1 to 3') do |chunk|
74+
chunks << chunk
75+
end
76+
end.to raise_error(expected_error_for(provider))
77+
end
78+
79+
it "#{provider}/#{model} supports handling streaming error events" do # rubocop:disable RSpec/ExampleLength
80+
skip('Error handling not implemented yet') unless error_handling_supported?(provider)
81+
82+
stub_error_response(provider, :event)
83+
84+
chunks = []
85+
86+
expect do
87+
chat.ask('Count from 1 to 3') do |chunk|
88+
chunks << chunk
89+
end
90+
end.to raise_error(expected_error_for(provider))
91+
end
92+
end
93+
94+
describe 'Faraday version 2' do # rubocop:disable RSpec/NestedGroups
95+
before do
96+
stub_const('Faraday::VERSION', '2.0.0')
97+
end
98+
99+
it "#{provider}/#{model} supports handling streaming error chunks" do # rubocop:disable RSpec/ExampleLength
100+
skip('Error handling not implemented yet') unless error_handling_supported?(provider)
101+
102+
stub_error_response(provider, :chunk)
103+
104+
chunks = []
105+
106+
expect do
107+
chat.ask('Count from 1 to 3') do |chunk|
108+
chunks << chunk
109+
end
110+
end.to raise_error(expected_error_for(provider))
111+
end
112+
113+
it "#{provider}/#{model} supports handling streaming error events" do # rubocop:disable RSpec/ExampleLength
114+
skip('Error handling not implemented yet') unless error_handling_supported?(provider)
115+
116+
stub_error_response(provider, :event)
117+
118+
chunks = []
119+
120+
expect do
121+
chat.ask('Count from 1 to 3') do |chunk|
122+
chunks << chunk
123+
end
124+
end.to raise_error(expected_error_for(provider))
125+
end
126+
end
127+
end
128+
end
129+
end
50130
end

spec/spec_helper.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
require 'fileutils'
4343
require 'ruby_llm'
4444
require 'webmock/rspec'
45+
require_relative 'support/streaming_error_helpers'
4546

4647
# VCR Configuration
4748
VCR.configure do |config|
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# frozen_string_literal: true
2+
3+
module StreamingErrorHelpers
4+
ERROR_HANDLING_CONFIGS = {
5+
anthropic: {
6+
url: 'https://api.anthropic.com/v1/messages',
7+
error_response: {
8+
type: 'error',
9+
error: {
10+
type: 'overloaded_error',
11+
message: 'Overloaded'
12+
}
13+
},
14+
chunk_status: 529,
15+
expected_error: RubyLLM::OverloadedError
16+
},
17+
openai: {
18+
url: 'https://api.openai.com/v1/chat/completions',
19+
error_response: {
20+
error: {
21+
message: 'The server is temporarily overloaded. Please try again later.',
22+
type: 'server_error',
23+
param: nil,
24+
code: nil
25+
}
26+
},
27+
chunk_status: 500,
28+
expected_error: RubyLLM::ServerError
29+
},
30+
gemini: {
31+
url: 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:streamGenerateContent?alt=sse',
32+
error_response: {
33+
error: {
34+
code: 529,
35+
message: 'Service overloaded - please try again later',
36+
status: 'RESOURCE_EXHAUSTED'
37+
}
38+
},
39+
chunk_status: 529,
40+
expected_error: RubyLLM::OverloadedError
41+
},
42+
deepseek: {
43+
url: 'https://api.deepseek.com/chat/completions',
44+
error_response: {
45+
error: {
46+
message: 'Service overloaded - please try again later',
47+
type: 'server_error',
48+
param: nil,
49+
code: nil
50+
}
51+
},
52+
chunk_status: 500,
53+
expected_error: RubyLLM::ServerError
54+
},
55+
openrouter: {
56+
url: 'https://openrouter.ai/api/v1/chat/completions',
57+
error_response: {
58+
error: {
59+
message: 'Service overloaded - please try again later',
60+
type: 'server_error',
61+
param: nil,
62+
code: nil
63+
}
64+
},
65+
chunk_status: 500,
66+
expected_error: RubyLLM::ServerError
67+
},
68+
ollama: {
69+
url: 'http://localhost:11434/v1/chat/completions',
70+
error_response: {
71+
error: {
72+
message: 'Service overloaded - please try again later',
73+
type: 'server_error',
74+
param: nil,
75+
code: nil
76+
}
77+
},
78+
chunk_status: 500,
79+
expected_error: RubyLLM::ServerError
80+
}
81+
}.freeze
82+
83+
def error_handling_supported?(provider)
84+
ERROR_HANDLING_CONFIGS.key?(provider)
85+
end
86+
87+
def expected_error_for(provider)
88+
ERROR_HANDLING_CONFIGS[provider][:expected_error]
89+
end
90+
91+
def stub_error_response(provider, type)
92+
config = ERROR_HANDLING_CONFIGS[provider]
93+
return unless config
94+
95+
body = case type
96+
when :chunk
97+
"#{config[:error_response].to_json}\n\n"
98+
when :event
99+
"event: error\ndata: #{config[:error_response].to_json}\n\n"
100+
end
101+
102+
status = type == :chunk ? config[:chunk_status] : 200
103+
104+
stub_request(:post, config[:url])
105+
.to_return(
106+
status: status,
107+
body: body,
108+
headers: { 'Content-Type' => 'text/event-stream' }
109+
)
110+
end
111+
end

0 commit comments

Comments
 (0)