# frozen_string_literal: true require "webmock/rspec" RSpec.describe Dispatch::Adapter::Claude, "#chat (streaming retry)" do let(:model_id) { "claude-sonnet-4-6" } let(:api_key) { "sk-ant-api03-test" } let(:base_url) { "https://api.anthropic.com" } subject(:adapter) do described_class.new( model: model_id, api_key: api_key, base_url: base_url ) end let(:messages) do [Dispatch::Adapter::Message.new( role: "user", content: [Dispatch::Adapter::TextBlock.new(text: "Hello")] )] end before do WebMock.disable_net_connect! # Suppress actual sleep to keep specs fast allow(adapter).to receive(:sleep) end after { WebMock.reset! } # ── Helpers ─────────────────────────────────────────────────────────────── # A complete, valid SSE stream with text content. def complete_sse_stream(text: "Hello!", input_tokens: 10, output_tokens: 5) <<~SSE event: message_start data: {"type":"message_start","message":{"id":"msg_01","model":"#{model_id}","usage":{"input_tokens":#{input_tokens},"output_tokens":0}}} event: content_block_start data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} event: content_block_delta data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"#{text}"}} event: content_block_stop data: {"type":"content_block_stop","index":0} event: message_delta data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":#{output_tokens}}} event: message_stop data: {"type":"message_stop"} SSE end def stub_stream(body:, status: 200, times: 1) stub_request(:post, "#{base_url}/v1/messages") .to_return( status: status, body: body, headers: { "Content-Type" => "text/event-stream" } ).times(times) end def stub_streams(*responses) # Chain multiple responses for successive retry attempts responses.reduce( stub_request(:post, "#{base_url}/v1/messages") ) { |stub, (status, body)| stub.to_return(status: status, body: body, headers: {}) } end # ── Happy path ──────────────────────────────────────────────────────────── describe "happy path (no retry needed)" do before { stub_stream(body: complete_sse_stream) } it "returns a Response" do response = adapter.chat(messages, stream: true) expect(response).to be_a(Dispatch::Adapter::Response) end it "yields StreamDelta events to the block" do deltas = [] adapter.chat(messages, stream: true) { |d| deltas << d } expect(deltas.map(&:type)).to include(:text_start, :text_delta, :text_end) end it "does not call sleep (no retry)" do adapter.chat(messages, stream: true) expect(adapter).not_to have_received(:sleep) end end # ── First-event timeout retry ───────────────────────────────────────────── describe "first-event timeout" do # We stub current_time_ms to simulate time advancing: # first call → t=0 (request_started_at) # subsequent calls → t > deadline, triggering timeout before do allow(adapter).to receive(:stream_first_event_timeout_ms).and_return(1_000) # 1 second # First attempt: "hangs" (empty body, no message_start) — we'll # simulate timeout by making time_ms advance past the deadline call_count = 0 allow(adapter).to receive(:current_time_ms) do call_count += 1 # First call: t=0 (baseline) # All subsequent calls: t=2000 (past deadline) call_count == 1 ? 0 : 2_000 end # First attempt: a stream that sends a chunk but never sees message_start # Second attempt (and beyond): complete stream stub_request(:post, "#{base_url}/v1/messages") .to_return( { status: 200, body: "event: ping\ndata: {}\n\n", headers: { "Content-Type" => "text/event-stream" } }, { status: 200, body: complete_sse_stream, headers: { "Content-Type" => "text/event-stream" } } ) end it "retries when first event times out and eventually returns a Response" do response = adapter.chat(messages, stream: true) expect(response).to be_a(Dispatch::Adapter::Response) end it "calls sleep between retries" do adapter.chat(messages, stream: true) expect(adapter).to have_received(:sleep).at_least(:once) end end # ── Stream ends before message_start ────────────────────────────────────── describe "stream ends before message_start" do before do stub_request(:post, "#{base_url}/v1/messages") .to_return( # 3 failures (empty streams), then a success { status: 200, body: "", headers: {} }, { status: 200, body: "", headers: {} }, { status: 200, body: "", headers: {} }, { status: 200, body: complete_sse_stream, headers: { "Content-Type" => "text/event-stream" } } ) end it "retries and eventually succeeds" do response = adapter.chat(messages, stream: true) expect(response).to be_a(Dispatch::Adapter::Response) expect(response.stop_reason).to eq(:end_turn) end it "sleeps between retries (exponential backoff)" do adapter.chat(messages, stream: true) expect(adapter).to have_received(:sleep).exactly(3).times end end # ── Stream ends before terminal ─────────────────────────────────────────── describe "stream ends without message_stop or message_delta" do let(:truncated_stream) do <<~SSE event: message_start data: {"type":"message_start","message":{"id":"msg_02","model":"#{model_id}","usage":{"input_tokens":5,"output_tokens":0}}} SSE end before do stub_request(:post, "#{base_url}/v1/messages") .to_return( { status: 200, body: truncated_stream, headers: { "Content-Type" => "text/event-stream" } }, { status: 200, body: complete_sse_stream, headers: { "Content-Type" => "text/event-stream" } } ) end it "retries and returns a Response" do response = adapter.chat(messages, stream: true) expect(response).to be_a(Dispatch::Adapter::Response) expect(response.stop_reason).to eq(:end_turn) end end # ── Retry exhausted: give-up returns :error Response ───────────────────── describe "retry exhausted (all attempts fail before message_start)" do before do stub_request(:post, "#{base_url}/v1/messages") .to_return(status: 200, body: "", headers: {}) .times(4) # initial + 3 retries = 4 total, all empty end it "returns a Response with stop_reason: :error" do response = adapter.chat(messages, stream: true) expect(response.stop_reason).to eq(:error) end it "does not raise" do expect { adapter.chat(messages, stream: true) }.not_to raise_error end it "sleeps exactly 3 times (one per retry)" do adapter.chat(messages, stream: true) expect(adapter).to have_received(:sleep).exactly(3).times end it "uses exponential backoff (2s, 4s, 8s)" do delays = [] allow(adapter).to receive(:sleep) { |s| delays << s } adapter.chat(messages, stream: true) # Base 2000ms × 2^(attempt-1); converted to seconds expect(delays).to eq([2.0, 4.0, 8.0]) end end # ── Mid-stream JSON corruption ──────────────────────────────────────────── describe "mid-stream JSON corruption BEFORE any text deltas" do let(:corrupted_stream_no_output) do # message_start arrives but no text deltas, then invalid SSE/JSON <<~SSE event: message_start data: {"type":"message_start","message":{"id":"msg_03","model":"#{model_id}","usage":{"input_tokens":5,"output_tokens":0}}} event: content_block_start data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} event: content_block_delta data: {BROKEN JSON SSE end before do stub_request(:post, "#{base_url}/v1/messages") .to_return( { status: 200, body: corrupted_stream_no_output, headers: { "Content-Type" => "text/event-stream" } }, { status: 200, body: complete_sse_stream, headers: { "Content-Type" => "text/event-stream" } } ) end it "retries and eventually succeeds when no output was emitted yet" do response = adapter.chat(messages, stream: true) # After retry succeeds we get a normal response expect(response).to be_a(Dispatch::Adapter::Response) end end describe "mid-stream JSON corruption AFTER text deltas emitted" do # A stream where text delta IS emitted to consumer (has_consumer_output? → true), # then the stream has a broken JSON frame → RequestError. # Since consumer output already happened, we do NOT retry. let(:corrupted_stream_with_output) do <<~SSE event: message_start data: {"type":"message_start","message":{"id":"msg_04","model":"#{model_id}","usage":{"input_tokens":5,"output_tokens":0}}} event: content_block_start data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} event: content_block_delta data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hello"}} event: content_block_delta data: {BROKEN JSON AFTER TEXT SSE end before do stub_stream(body: corrupted_stream_with_output) end it "does NOT retry when output has been emitted — raises the error" do # RequestError from parse_frame; consumer_output? is true → not safe to retry → raises expect { adapter.chat(messages, stream: true) } .to raise_error(Dispatch::Adapter::RequestError, /invalid JSON|incomplete frame/i) end it "does not call sleep (no retry)" do begin adapter.chat(messages, stream: true) rescue StandardError nil end expect(adapter).not_to have_received(:sleep) end end # ── Connection error before any output ──────────────────────────────────── describe "ConnectionError before message_start" do # Simulate a connection error that causes ConnectionError (wrapped by HttpClient), # then a successful response on the retry. before do # First call: make the stub raise a connection-level error stub_request(:post, "#{base_url}/v1/messages") .to_return( { status: 200, body: "", headers: { "Content-Type" => "text/event-stream" } }, { status: 200, body: complete_sse_stream, headers: { "Content-Type" => "text/event-stream" } } ) end it "retries when stream ends before message_start and succeeds" do # An empty response also triggers :no_message_start retry response = adapter.chat(messages, stream: true) expect(response.stop_reason).to eq(:end_turn) end end # ── Non-retriable error (e.g. 401) ──────────────────────────────────────── describe "AuthenticationError is not retried" do before do stub_request(:post, "#{base_url}/v1/messages") .to_return( status: 401, body: JSON.generate({ "error" => { "message" => "Unauthorized" } }), headers: { "Content-Type" => "application/json" } ) end it "raises AuthenticationError without retrying" do expect { adapter.chat(messages, stream: true) } .to raise_error(Dispatch::Adapter::AuthenticationError) end it "does not sleep (no retry)" do begin adapter.chat(messages, stream: true) rescue StandardError nil end expect(adapter).not_to have_received(:sleep) end end # ── Constants accessible ────────────────────────────────────────────────── describe "constants" do it "STREAM_MAX_RETRIES is 3" do expect(described_class::STREAM_MAX_RETRIES).to eq(3) end it "STREAM_BASE_DELAY_MS is 2000" do expect(described_class::STREAM_BASE_DELAY_MS).to eq(2_000) end it "STREAM_FIRST_EVENT_TIMEOUT_MS is a positive integer" do expect(described_class::STREAM_FIRST_EVENT_TIMEOUT_MS).to be > 0 end end end