Summary
When a streaming request gets a non-200 response whose body parses to
a JSON value that is not a Hash (e.g. a bare JSON string), the OpenAI
provider's parse_streaming_error raises TypeError: String does not have #dig instead of surfacing the provider's error message. The
identical body is handled correctly on the non-streaming path, so the
real error message is lost only when streaming.
Version: ruby_llm 1.16.0 (latest), Ruby 3.3.11, faraday 2.14.2.
Root cause
Two co-located issues in the streaming error path:
-
RubyLLM::Streaming#handle_failed_response (lib/ruby_llm/streaming.rb)
calls handle_parsed_error with no is_a?(Hash) guard — unlike the
success path handle_data, which does return parsed unless parsed.is_a?(Hash) && parsed.key?('error').
-
RubyLLM::Providers::OpenAI::Streaming#parse_streaming_error
(lib/ruby_llm/providers/openai/streaming.rb:39) assumes a Hash:
def parse_streaming_error(data)
error_data = JSON.parse(data)
return unless error_data['error'] # ← intended as a key-presence check
case error_data.dig('error', 'type') # ← line 43: String#dig => TypeError
...
end
The guard return unless error_data['error'] is meant to test for a
"error" key, but when error_data is a String, String#[]('error')
does substring matching. A body that parses to a string containing
the substring error slips past the guard and reaches .dig, which
String doesn't implement.
(If the string does not contain error, the guard instead returns
nil and the real error is silently swallowed — a quieter variant of
the same missing-type-check.)
By contrast, the non-streaming RubyLLM::Provider#parse_error
(lib/ruby_llm/provider.rb) is robust — it cases on Hash / Array /
else, returning the raw body as the message in the else arm.
Reproduction
require 'ruby_llm'
# A gateway/proxy returning a bare JSON string for an unavailable model,
# Content-Type: application/json, HTTP 404 — this reaches the streaming
# failed-response path.
body = %q("The model foo is not available in your region (error).")
RubyLLM::Providers::OpenAI::Streaming.parse_streaming_error(body)
# => TypeError: String does not have #dig method
# .../providers/openai/streaming.rb:43:in `parse_streaming_error'
# The non-streaming path handles the identical body fine:
RubyLLM.configure { |c| c.openai_api_key = 'x' }
prov = RubyLLM::Providers::OpenAI.new(RubyLLM.config)
resp = Struct.new(:body, :status).new(body, 404)
prov.parse_error(resp)
# => "The model foo is not available in your region (error)."
Real-world trigger: pointing an OpenAI-compatible client at a model/region
the endpoint rejects (in my case Grok via an OpenAI-compatible endpoint,
not yet available in Europe) while streaming. The user sees a cryptic
Ruby TypeError in place of the endpoint's actual "not available"
message.
Full backtrace from the app:
TypeError: String does not have #dig method
ruby_llm-1.16.0/lib/ruby_llm/providers/openai/streaming.rb:43:in `dig'
ruby_llm-1.16.0/lib/ruby_llm/providers/openai/streaming.rb:43:in `parse_streaming_error'
ruby_llm-1.16.0/lib/ruby_llm/streaming.rb:128:in `handle_parsed_error'
ruby_llm-1.16.0/lib/ruby_llm/streaming.rb:90:in `handle_failed_response'
ruby_llm-1.16.0/lib/ruby_llm/streaming.rb:54:in `block in build_on_data_handler'
...
Suggested fix
Guard for a non-Hash parsed body, mirroring the non-streaming
Provider#parse_error. Minimal version in the OpenAI provider:
def parse_streaming_error(data)
error_data = JSON.parse(data)
return [500, error_data] unless error_data.is_a?(Hash) && error_data['error']
case error_data.dig('error', 'type')
when 'server_error'
[500, error_data['error']['message']]
when 'rate_limit_exceeded', 'insufficient_quota'
[429, error_data['error']['message']]
else
[400, error_data['error']['message']]
end
end
and/or add the is_a?(Hash) guard in Streaming#handle_failed_response
so the failed-response path matches handle_data's existing check. Happy
to open a PR if the direction looks right.
Summary
When a streaming request gets a non-200 response whose body parses to
a JSON value that is not a Hash (e.g. a bare JSON string), the OpenAI
provider's
parse_streaming_errorraisesTypeError: String does not have #diginstead of surfacing the provider's error message. Theidentical body is handled correctly on the non-streaming path, so the
real error message is lost only when streaming.
Version: ruby_llm 1.16.0 (latest), Ruby 3.3.11, faraday 2.14.2.
Root cause
Two co-located issues in the streaming error path:
RubyLLM::Streaming#handle_failed_response(lib/ruby_llm/streaming.rb)calls
handle_parsed_errorwith nois_a?(Hash)guard — unlike thesuccess path
handle_data, which doesreturn parsed unless parsed.is_a?(Hash) && parsed.key?('error').RubyLLM::Providers::OpenAI::Streaming#parse_streaming_error(
lib/ruby_llm/providers/openai/streaming.rb:39) assumes a Hash:The guard
return unless error_data['error']is meant to test for a"error"key, but whenerror_datais aString,String#[]('error')does substring matching. A body that parses to a string containing
the substring
errorslips past the guard and reaches.dig, whichStringdoesn't implement.(If the string does not contain
error, the guard instead returnsniland the real error is silently swallowed — a quieter variant ofthe same missing-type-check.)
By contrast, the non-streaming
RubyLLM::Provider#parse_error(
lib/ruby_llm/provider.rb) is robust — itcases onHash/Array/else, returning the raw body as the message in theelsearm.Reproduction
Real-world trigger: pointing an OpenAI-compatible client at a model/region
the endpoint rejects (in my case Grok via an OpenAI-compatible endpoint,
not yet available in Europe) while streaming. The user sees a cryptic
Ruby
TypeErrorin place of the endpoint's actual "not available"message.
Full backtrace from the app:
Suggested fix
Guard for a non-Hash parsed body, mirroring the non-streaming
Provider#parse_error. Minimal version in the OpenAI provider:and/or add the
is_a?(Hash)guard inStreaming#handle_failed_responseso the failed-response path matches
handle_data's existing check. Happyto open a PR if the direction looks right.