Skip to content

Streaming: parse_streaming_error crashes with 'TypeError: String does not have #dig' on a non-Hash JSON error body #837

Description

@mvysny

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:

  1. 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').

  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions