Skip to content

fix: guard StreamAPIException against empty / non-integer error bodies#217

Merged
Loschcode merged 1 commit into
masterfrom
fix/cha-3655-stream-api-exception-empty-body
Jun 30, 2026
Merged

fix: guard StreamAPIException against empty / non-integer error bodies#217
Loschcode merged 1 commit into
masterfrom
fix/cha-3655-stream-api-exception-empty-body

Conversation

@Loschcode

Copy link
Copy Markdown
Contributor

Ticket

Problem

StreamAPIException#error_code (Integer) and #error_message (String) are typed as non-nilable, but the constructor only assigned them on the JSON happy path:

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   # @error_code / @error_message left unset (nil)
end

Two real failure modes hit this:

  1. Empty / non-JSON body (this ticket). When the API is fronted by a load balancer that emits a bare 503 with an empty body, JSON.parse("") raises JSON::ParserError. The rescue sets @json_response = false but never assigns @error_code/@error_message, so they stay nil. The exception constructs fine, but the moment a caller reads e.error_code (e.g. for logging) the Sorbet sig { returns(Integer) } on the attr_reader runtime-checks the value, sees nil, and raises TypeError: Expected type Integer, got NilClass (errors.rb:9) — masking the real HTTP error.

    Reported by Homebase on stream-chat-ruby 3.9.0:

    TypeError: Return value: Expected type Integer, got type NilClass
      at StreamChat::StreamAPIException#error_code (errors.rb:9)
    Caused by: StreamChat::StreamAPIException: StreamChat error HTTP code: 503
    Caused by: JSON::ParserError: An empty string is not a valid JSON string
    
  2. Non-integer code. An edge rate-limit envelope returning code: "rate_limit_exceeded" made T.let(..., Integer) raise a TypeError inside initialize, so callers couldn't even rescue it as a StreamAPIException (previously reported, ticket #80594).

Fix

Seed both readers with sentinels (-1 / 'unknown') before parsing, and only overwrite them when the parsed body is a Hash carrying values of the expected type:

  • error_code is always an Integer — never nil, never a crash on read.
  • message() still falls back to the HTTP status when there is no usable JSON body.
  • A missing, non-integer, or non-Hash body no longer raises.

This fixes both failure modes at the source rather than relying on the backend to keep emitting a shape the SDK happens to tolerate.

Tests

New network-free spec/errors_spec.rb covering: well-formed JSON, empty 503 body, non-JSON body, non-integer code, and a JSON body missing code/message.

6 examples, 0 failures        # bundle exec rspec spec/errors_spec.rb
2 files inspected, no offenses # bundle exec rubocop
No errors! Great job.          # bundle exec srb tc

Note for reviewers

lib/stream-chat/errors.rb is hand-written (no codegen marker). If the intent is for SDK error types to be self-generated, the same guard should be applied in the generator template instead — flagging for that discussion.

Checklist

  • Unit tests added
  • RuboCop + Sorbet pass
  • CHANGELOG handled by standard-version on release (conventional fix: commit)

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) <noreply@anthropic.com>
@Loschcode Loschcode merged commit 415f6c0 into master Jun 30, 2026
9 checks passed
@Loschcode Loschcode deleted the fix/cha-3655-stream-api-exception-empty-body branch June 30, 2026 09:49
@github-actions github-actions Bot mentioned this pull request Jun 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants