From eb7ea86e9df5b7133ce99df08cee5bc63de8ad34 Mon Sep 17 00:00:00 2001 From: Laurent Schaffner Date: Tue, 30 Jun 2026 10:58:37 +0200 Subject: [PATCH] fix: guard StreamAPIException against empty and non-integer error bodies StreamAPIException#error_code and #error_message are typed as non-nilable Integer/String, but the constructor only assigned them on the happy path. When the HTTP body was empty or non-JSON (e.g. a load balancer emitting a bare 503), the JSON::ParserError rescue left both unset, so any caller that read error_code hit a Sorbet TypeError (Expected Integer, got NilClass) that masked the underlying HTTP failure. A non-integer "code" (seen on edge rate-limit responses) raised the same TypeError from inside initialize. Seed both readers with sentinels (-1 / 'unknown') before parsing and only overwrite them when the parsed body is a Hash carrying values of the right type. error_code is now always an Integer, message() still falls back to the HTTP status, and a missing or non-integer code no longer raises. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/stream-chat/errors.rb | 22 ++++++++++--- spec/errors_spec.rb | 68 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 spec/errors_spec.rb diff --git a/lib/stream-chat/errors.rb b/lib/stream-chat/errors.rb index 4e5a353..4959406 100644 --- a/lib/stream-chat/errors.rb +++ b/lib/stream-chat/errors.rb @@ -21,14 +21,28 @@ class StreamAPIException < StandardError def initialize(response) super() @response = response + + # Seed defaults first so the typed readers never return nil. A 5xx can + # arrive with an empty or non-JSON body (e.g. a load balancer emitting a + # bare 503), and an error envelope may omit "code"/"message" or send a + # non-integer "code". Without these defaults, reading error_code on such + # an exception raised a Sorbet TypeError that masked the real HTTP error. + @json_response = T.let(false, T::Boolean) + @error_code = T.let(-1, Integer) + @error_message = T.let('unknown', String) + begin parsed_response = JSON.parse(response.body) - @json_response = T.let(true, T::Boolean) - @error_code = T.let(parsed_response.fetch('code', 'unknown'), Integer) - @error_message = T.let(parsed_response.fetch('message', 'unknown'), String) rescue JSON::ParserError - @json_response = false + return end + return unless parsed_response.is_a?(Hash) + + @json_response = true + code = parsed_response['code'] + @error_code = code if code.is_a?(Integer) + msg = parsed_response['message'] + @error_message = msg if msg.is_a?(String) end sig { returns(String) } diff --git a/spec/errors_spec.rb b/spec/errors_spec.rb new file mode 100644 index 0000000..d01b647 --- /dev/null +++ b/spec/errors_spec.rb @@ -0,0 +1,68 @@ +# typed: false +# frozen_string_literal: true + +require 'stream-chat' + +describe StreamChat::StreamAPIException do + def response_with(status, body) + Faraday::Response.new(Faraday::Env.from(status: status, body: body)) + end + + context 'with a well-formed JSON error body' do + subject(:exception) do + described_class.new(response_with(429, { code: 9, message: 'rate limit' }.to_json)) + end + + it 'exposes the integer code and message' do + expect(exception.json_response).to be true + expect(exception.error_code).to eq(9) + expect(exception.error_message).to eq('rate limit') + expect(exception.message).to eq('StreamChat error code 9: rate limit') + end + end + + context 'with a 503 and an empty body' do + subject(:exception) { described_class.new(response_with(503, '')) } + + it 'does not raise when the typed readers are accessed' do + expect(exception.json_response).to be false + expect(exception.error_code).to eq(-1) + expect(exception.error_message).to eq('unknown') + end + + it 'falls back to the HTTP status in the message' do + expect(exception.message).to eq('StreamChat error HTTP code: 503') + end + end + + context 'with a non-JSON body' do + subject(:exception) { described_class.new(response_with(502, '502 Bad Gateway')) } + + it 'falls back to defaults and the HTTP status' do + expect(exception.json_response).to be false + expect(exception.error_code).to eq(-1) + expect(exception.message).to eq('StreamChat error HTTP code: 502') + end + end + + context 'with a non-integer code (edge rate-limit envelope)' do + subject(:exception) do + described_class.new(response_with(429, { code: 'rate_limit_exceeded', message: 'slow down' }.to_json)) + end + + it 'keeps the sentinel code rather than raising a TypeError' do + expect(exception.json_response).to be true + expect(exception.error_code).to eq(-1) + expect(exception.error_message).to eq('slow down') + end + end + + context 'with a JSON body missing code and message' do + subject(:exception) { described_class.new(response_with(500, {}.to_json)) } + + it 'returns the default code and message' do + expect(exception.error_code).to eq(-1) + expect(exception.error_message).to eq('unknown') + end + end +end