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