Skip to content

Commit 3fa8597

Browse files
authored
fix(model): classify empty provider streams as retryable (#183)
Bedrock intermittently terminates ConverseStream responses without ever starting an assistant message when a model emits an empty completion (observed in production on Haiku 4.5 as messageStop with no prior messageStart and zero output tokens). The strict stream state machine correctly rejects the shape but surfaced it as an opaque protocol error that retry middleware could not detect. Introduce model.ErrEmptyStream plus model.NewEmptyStreamError, which pair the sentinel with a retryable unavailable ProviderError (code empty_stream). The Bedrock and Anthropic adapters classify message stop without an active message and event streams that close before message start through it, and classify streams that close mid-message as retryable truncated_stream provider errors. Duplicate message stops stay hard protocol errors. Adapters still never fabricate responses; retry policy remains with the integrating application.
1 parent 2f26eb4 commit 3fa8597

10 files changed

Lines changed: 282 additions & 13 deletions

File tree

DESIGN.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,32 @@ that UIs and stream bridges can consume without heuristics.
261261

262262
This keeps consumers simple: render `error`, gate “Retry” on `retryable`, and treat `canceled` as non-error.
263263

264+
## Provider Stream Integrity Contract
265+
266+
Provider adapters (Bedrock, Anthropic) validate the streaming event protocol
267+
with a strict state machine: a message must start before content blocks flow
268+
and must stop exactly once before metadata. Violations never produce a
269+
fabricated response; they fail the stream with a precise error. Two terminal
270+
shapes are classified instead of surfaced as opaque protocol errors:
271+
272+
- **Empty stream** — the stream terminates before any message starts (a
273+
`messageStop` with no prior `messageStart`, or a stream that closes with no
274+
events at all). Providers intermittently do this when a model emits an
275+
empty completion. Adapters build the error with `model.NewEmptyStreamError`,
276+
which carries the `model.ErrEmptyStream` sentinel plus a retryable
277+
`unavailable` ProviderError (code `empty_stream`). Callers detect it with
278+
`errors.Is(err, model.ErrEmptyStream)` and may retry the request a bounded
279+
number of times before surfacing the failure.
280+
- **Truncated stream** — the stream closes cleanly after a message started but
281+
before `messageStop`. Adapters classify it as a retryable `unavailable`
282+
ProviderError (code `truncated_stream`) without the empty-stream sentinel:
283+
output was partially produced, so blind pre-output retry policies must not
284+
match it.
285+
286+
Adapters never retry internally; retry policy belongs to the integrating
287+
application (for example, a guard that retries only before the first visible
288+
output chunk).
289+
264290
## Runtime Tracing Error Contract
265291

266292
The runtime uses one generic rule for span failures across model clients and

docs/runtime.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2361,8 +2361,15 @@ var ErrNotFound = errors.New("run not found") // run.ErrNotFound
23612361
```go
23622362
var ErrStreamingUnsupported = errors.New("model: streaming not supported")
23632363
var ErrRateLimited = errors.New("model: rate limited")
2364+
var ErrEmptyStream = errors.New("model: provider returned an empty stream")
23642365
```
23652366

2367+
`ErrEmptyStream` is joined (via `model.NewEmptyStreamError`) with a retryable
2368+
`unavailable` ProviderError when a provider terminates a stream before any
2369+
assistant message starts — the wire shape produced by intermittent empty model
2370+
completions. Detect it with `errors.Is(err, model.ErrEmptyStream)` and retry
2371+
the request a bounded number of times before surfacing the failure.
2372+
23662373
---
23672374

23682375
## Best Practices

features/model/anthropic/client.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ type (
8686
}
8787
)
8888

89+
// anthropicProviderName identifies this adapter in model.ProviderError values.
90+
const anthropicProviderName = "anthropic"
91+
8992
// New builds an Anthropic-backed model client from the provided Anthropic
9093
// Messages client and configuration options.
9194
func New(msg MessagesClient, opts Options) (*Client, error) {
@@ -625,7 +628,7 @@ func wrapAnthropicError(operation string, err error) error {
625628
} else {
626629
message = err.Error()
627630
}
628-
return model.ClassifyHTTPStatus("anthropic", operation, status, message, err)
631+
return model.ClassifyHTTPStatus(anthropicProviderName, operation, status, message, err)
629632
}
630633

631634
// anthropicErrorMessage safely renders an *sdk.Error's message.

features/model/anthropic/stream.go

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ func (s *anthropicStreamer) run() {
113113
} else if err := s.ctx.Err(); err != nil {
114114
s.setErr(err)
115115
} else if !processor.complete {
116-
s.setErr(errors.New("anthropic: stream ended before message_stop"))
116+
s.setErr(streamEndedEarlyError(processor.started))
117117
} else {
118118
translated, err := translateResponse(&response, s.toolNameMap)
119119
if err != nil {
@@ -410,8 +410,18 @@ func (p *anthropicChunkProcessor) Handle(event sdk.MessageStreamEventUnion) erro
410410
}
411411
return p.emit(model.UsageChunk{Usage: usage})
412412
case sdk.MessageStopEvent:
413-
if !p.started || p.complete {
414-
return errors.New("anthropic stream: message stop received without an active message")
413+
if !p.started {
414+
// Anthropic models intermittently emit an empty completion whose
415+
// stream stops a message that never started. Classify as a
416+
// retryable empty stream instead of an opaque protocol error.
417+
return model.NewEmptyStreamError(
418+
anthropicProviderName,
419+
"stream_recv",
420+
"message stop received without an active message",
421+
)
422+
}
423+
if p.complete {
424+
return errors.New("anthropic stream: duplicate message stop")
415425
}
416426
if len(p.openBlocks) > 0 {
417427
return fmt.Errorf("anthropic stream: message stopped with %d open content blocks", len(p.openBlocks))
@@ -490,3 +500,30 @@ func decodeToolPayload(raw string) (rawjson.Message, error) {
490500
}
491501
return rawjson.Message(data), nil
492502
}
503+
504+
// streamEndedEarlyError classifies an Anthropic event stream that closed
505+
// cleanly before message stop. When no message ever started, the provider
506+
// produced an empty completion and callers may retry (model.ErrEmptyStream).
507+
// When a message was underway, the stream was truncated mid-generation: a
508+
// fresh request regenerates the full response, so the failure is a retryable
509+
// provider fault but not an empty stream.
510+
func streamEndedEarlyError(started bool) error {
511+
if !started {
512+
return model.NewEmptyStreamError(
513+
anthropicProviderName,
514+
"stream_recv",
515+
"stream ended before message start",
516+
)
517+
}
518+
return model.NewProviderError(
519+
anthropicProviderName,
520+
"stream_recv",
521+
0,
522+
model.ProviderErrorKindUnavailable,
523+
"truncated_stream",
524+
"stream ended before message stop",
525+
"",
526+
true,
527+
nil,
528+
)
529+
}

features/model/anthropic/stream_test.go

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -223,19 +223,44 @@ func TestAnthropicStreamer_ContextCancelPassthrough(t *testing.T) {
223223
assert.False(t, ok)
224224
}
225225

226-
// TestAnthropicStreamerRejectsEOFBeforeCanonicalResponse verifies that a stream
227-
// cannot terminate successfully without the provider's message_stop boundary.
228-
func TestAnthropicStreamerRejectsEOFBeforeCanonicalResponse(t *testing.T) {
226+
// TestAnthropicStreamerClassifiesEventlessStreamAsEmptyStream verifies that a
227+
// stream closing before any message starts is classified as a retryable empty
228+
// stream (model.ErrEmptyStream) instead of an opaque protocol error, so retry
229+
// middleware can safely reissue the request.
230+
func TestAnthropicStreamerClassifiesEventlessStreamAsEmptyStream(t *testing.T) {
229231
dec := &testDecoder{events: nil}
230232
stream := ssestream.NewStream[sdk.MessageStreamEventUnion](dec, nil)
231233

232234
s := newAnthropicStreamer(context.Background(), stream, nil)
233235
defer func() { _ = s.Close() }()
234236

235237
_, err := s.Recv()
236-
require.EqualError(t, err, "anthropic: stream ended before message_stop")
237-
_, ok := model.AsProviderError(err)
238-
assert.False(t, ok)
238+
require.ErrorIs(t, err, model.ErrEmptyStream)
239+
pe, ok := model.AsProviderError(err)
240+
require.True(t, ok)
241+
assert.Equal(t, model.ProviderErrorKindUnavailable, pe.Kind())
242+
assert.True(t, pe.Retryable())
243+
}
244+
245+
// TestAnthropicStreamerClassifiesMessageStopWithoutStartAsEmptyStream verifies
246+
// that a message_stop arriving before message_start carries the empty-stream
247+
// classification: this is the wire shape Anthropic-family models produce when
248+
// they emit an empty completion.
249+
func TestAnthropicStreamerClassifiesMessageStopWithoutStartAsEmptyStream(t *testing.T) {
250+
var stop sdk.MessageStreamEventUnion
251+
require.NoError(t, json.Unmarshal([]byte(`{"type":"message_stop"}`), &stop))
252+
events := []ssestream.Event{{Type: "message_stop", Data: mustJSON(stop)}}
253+
stream := ssestream.NewStream[sdk.MessageStreamEventUnion](&testDecoder{events: events}, nil)
254+
255+
s := newAnthropicStreamer(context.Background(), stream, nil)
256+
defer func() { _ = s.Close() }()
257+
258+
_, err := s.Recv()
259+
require.ErrorIs(t, err, model.ErrEmptyStream)
260+
pe, ok := model.AsProviderError(err)
261+
require.True(t, ok)
262+
assert.Equal(t, model.ProviderErrorKindUnavailable, pe.Kind())
263+
assert.True(t, pe.Retryable())
239264
}
240265

241266
func TestAnthropicStreamerRejectsMessageStopWithOpenContentBlock(t *testing.T) {

features/model/bedrock/stream.go

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ func (s *bedrockStreamer) run() {
127127
} else if err := s.ctx.Err(); err != nil {
128128
s.setErr(err)
129129
} else if !processor.complete {
130-
s.setErr(errors.New("bedrock: stream ended before message stop"))
130+
s.setErr(streamEndedEarlyError(processor.started))
131131
} else if err := processor.finishStream(); err != nil {
132132
s.setErr(err)
133133
} else {
@@ -489,8 +489,18 @@ func (p *chunkProcessor) Handle(event any) error {
489489
}
490490
return nil
491491
case *brtypes.ConverseStreamOutputMemberMessageStop:
492-
if !p.started || p.complete {
493-
return errors.New("bedrock stream: message stop received without an active message")
492+
if !p.started {
493+
// Bedrock intermittently stops a message it never started when the
494+
// model produces an empty completion (observed on Haiku). Classify
495+
// as a retryable empty stream instead of an opaque protocol error.
496+
return model.NewEmptyStreamError(
497+
bedrockProviderName,
498+
"converse_stream",
499+
"message stop received without an active message",
500+
)
501+
}
502+
if p.complete {
503+
return errors.New("bedrock stream: duplicate message stop")
494504
}
495505
if len(p.openBlocks) > 0 {
496506
return fmt.Errorf("bedrock stream: message stopped with %d open content blocks", len(p.openBlocks))
@@ -579,6 +589,33 @@ func (p *chunkProcessor) finishStream() error {
579589
return p.emit(model.StopChunk{Reason: p.canonical.StopReason})
580590
}
581591

592+
// streamEndedEarlyError classifies a Bedrock event stream that closed cleanly
593+
// before message stop. When no message ever started, the provider produced an
594+
// empty completion and callers may retry (model.ErrEmptyStream). When a
595+
// message was underway, the stream was truncated mid-generation: a fresh
596+
// request regenerates the full response, so the failure is a retryable
597+
// provider fault but not an empty stream.
598+
func streamEndedEarlyError(started bool) error {
599+
if !started {
600+
return model.NewEmptyStreamError(
601+
bedrockProviderName,
602+
"converse_stream",
603+
"stream ended before message start",
604+
)
605+
}
606+
return model.NewProviderError(
607+
bedrockProviderName,
608+
"converse_stream",
609+
0,
610+
model.ProviderErrorKindUnavailable,
611+
"truncated_stream",
612+
"stream ended before message stop",
613+
"",
614+
true,
615+
nil,
616+
)
617+
}
618+
582619
type toolBuffer struct {
583620
name string
584621
id string

features/model/bedrock/stream_usage_test.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,3 +319,85 @@ func TestChunkProcessorRejectsMessageStopWithOpenContentBlock(t *testing.T) {
319319

320320
require.EqualError(t, err, "bedrock stream: message stopped with 1 open content blocks")
321321
}
322+
323+
// TestChunkProcessorClassifiesMessageStopWithoutStartAsEmptyStream verifies
324+
// that a messageStop arriving before messageStart is classified as a
325+
// retryable empty stream (model.ErrEmptyStream). Bedrock intermittently
326+
// produces this wire shape when the model emits an empty completion, so retry
327+
// middleware must be able to detect it without string matching.
328+
func TestChunkProcessorClassifiesMessageStopWithoutStartAsEmptyStream(t *testing.T) {
329+
cp := newChunkProcessor(
330+
func(model.Chunk) error { return nil },
331+
map[string]string{},
332+
"test-model-id",
333+
model.ModelClassDefault,
334+
nil,
335+
)
336+
337+
err := cp.Handle(&brtypes.ConverseStreamOutputMemberMessageStop{
338+
Value: brtypes.MessageStopEvent{StopReason: brtypes.StopReasonEndTurn},
339+
})
340+
341+
require.ErrorIs(t, err, model.ErrEmptyStream)
342+
pe, ok := model.AsProviderError(err)
343+
require.True(t, ok)
344+
require.Equal(t, model.ProviderErrorKindUnavailable, pe.Kind())
345+
require.Equal(t, "empty_stream", pe.Code())
346+
require.True(t, pe.Retryable())
347+
}
348+
349+
// TestChunkProcessorRejectsDuplicateMessageStop verifies that a second
350+
// messageStop after a completed message stays a hard protocol error and is
351+
// not mistaken for an empty stream.
352+
func TestChunkProcessorRejectsDuplicateMessageStop(t *testing.T) {
353+
cp := newChunkProcessor(
354+
func(model.Chunk) error { return nil },
355+
map[string]string{},
356+
"test-model-id",
357+
model.ModelClassDefault,
358+
nil,
359+
)
360+
361+
require.NoError(t, cp.Handle(&brtypes.ConverseStreamOutputMemberMessageStart{}))
362+
require.NoError(t, cp.Handle(&brtypes.ConverseStreamOutputMemberMessageStop{
363+
Value: brtypes.MessageStopEvent{StopReason: brtypes.StopReasonEndTurn},
364+
}))
365+
err := cp.Handle(&brtypes.ConverseStreamOutputMemberMessageStop{
366+
Value: brtypes.MessageStopEvent{StopReason: brtypes.StopReasonEndTurn},
367+
})
368+
369+
require.EqualError(t, err, "bedrock stream: duplicate message stop")
370+
require.NotErrorIs(t, err, model.ErrEmptyStream)
371+
}
372+
373+
// TestStreamEndedEarlyErrorClassification verifies the two terminal shapes of
374+
// a Bedrock event stream that closes before messageStop: never-started
375+
// streams are retryable empty streams, mid-message closes are retryable
376+
// truncations that must not carry the empty-stream sentinel.
377+
func TestStreamEndedEarlyErrorClassification(t *testing.T) {
378+
tests := []struct {
379+
name string
380+
started bool
381+
wantEmpty bool
382+
wantCode string
383+
}{
384+
{name: "never started", started: false, wantEmpty: true, wantCode: "empty_stream"},
385+
{name: "truncated mid message", started: true, wantEmpty: false, wantCode: "truncated_stream"},
386+
}
387+
for _, tt := range tests {
388+
t.Run(tt.name, func(t *testing.T) {
389+
err := streamEndedEarlyError(tt.started)
390+
391+
if tt.wantEmpty {
392+
require.ErrorIs(t, err, model.ErrEmptyStream)
393+
} else {
394+
require.NotErrorIs(t, err, model.ErrEmptyStream)
395+
}
396+
pe, ok := model.AsProviderError(err)
397+
require.True(t, ok)
398+
require.Equal(t, model.ProviderErrorKindUnavailable, pe.Kind())
399+
require.Equal(t, tt.wantCode, pe.Code())
400+
require.True(t, pe.Retryable())
401+
})
402+
}
403+
}

runtime/agent/model/model.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -926,6 +926,15 @@ var ErrStructuredOutputUnsupported = errors.New("model: structured output not su
926926
// failure that is safe to surface to higher layers.
927927
var ErrRateLimited = errors.New("model: rate limited")
928928

929+
// ErrEmptyStream indicates the provider terminated a streaming response
930+
// without ever starting an assistant message, so no output was produced.
931+
// Providers intermittently do this when a model emits an empty completion
932+
// (observed on Bedrock as messageStop with no prior messageStart). Adapters
933+
// classify the condition with NewEmptyStreamError instead of fabricating an
934+
// empty response; callers detect it with errors.Is and may retry the request
935+
// a bounded number of times before surfacing the failure.
936+
var ErrEmptyStream = errors.New("model: provider returned an empty stream")
937+
929938
// ToolCalls derives model tool invocations from their ordered ToolUsePart
930939
// entries. Response.Content remains the single source of provider output.
931940
func (r *Response) ToolCalls() []ToolCall {

runtime/agent/model/provider_error.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ const (
2929
ProviderErrorKindUnknown ProviderErrorKind = "unknown"
3030
)
3131

32+
// providerErrorCodeEmptyStream is the stable code carried by empty-stream
33+
// provider errors. Detection goes through errors.Is(err, ErrEmptyStream);
34+
// the code exists for observability (span/error attributes), not matching.
35+
const providerErrorCodeEmptyStream = "empty_stream"
36+
3237
// ProviderError describes a failure returned by a model provider (e.g. Bedrock).
3338
// It is intended to cross package boundaries so runtimes can surface stable,
3439
// structured information to callers.
@@ -125,6 +130,30 @@ func AsProviderError(err error) (*ProviderError, bool) {
125130
return nil, false
126131
}
127132

133+
// NewEmptyStreamError classifies a streaming response that the provider
134+
// terminated without ever starting an assistant message. Adapters call it
135+
// from their stream event loops when the terminal event (or the end of the
136+
// event stream) arrives before any message started, which providers
137+
// intermittently produce for empty model completions. The result carries
138+
// ErrEmptyStream for errors.Is detection plus a retryable unavailable
139+
// ProviderError so retry middleware and observability see one consistent
140+
// classification. message describes the protocol shape observed (for
141+
// example, "message stop received without an active message").
142+
func NewEmptyStreamError(provider, operation, message string) error {
143+
pe := NewProviderError(
144+
provider,
145+
operation,
146+
0,
147+
ProviderErrorKindUnavailable,
148+
providerErrorCodeEmptyStream,
149+
message,
150+
"",
151+
true,
152+
nil,
153+
)
154+
return errors.Join(ErrEmptyStream, pe)
155+
}
156+
128157
// ClassifyHTTPStatus maps an HTTP status code returned by a model provider to
129158
// the goa-ai provider error contract. It is the single status-to-kind table
130159
// for adapters that need only status-based classification (Vertex Gemini,

0 commit comments

Comments
 (0)