Skip to content

Commit 2fe3362

Browse files
Log, contain, and cleanly surface Bedrock mid-stream errors on the fable path (#68)
A Claude Code client saw 'Server error mid-response' while the daemon logged nothing: transcodeBedrockToSSE dropped AWS event-stream exception frames (their payload is not {"bytes":...}-wrapped), swallowed read errors, and never logged mid-stream anomalies. - Parse event-stream headers (:message-type/:exception-type/:event-type) in the frame scanner; malformed headers degrade to headerless payload extraction, never panic. - Log every mid-stream exception, in-band error event, and truncation with bedrock_source/region; clean streams stay byte-identical and log-silent. - Translate exceptions into well-formed Anthropic SSE error events (throttling -> overloaded_error, else api_error) and synthesize one on truncation, so clients get a clean retryable error instead of a dead stream. - Peek the first decoded frame before committing to the streaming 200: exception-first or empty streams become a synthetic 503, so serveClaudeFableBedrockPrimary falls through to the subscription pool and the client never sees the failure.
1 parent c5e1831 commit 2fe3362

3 files changed

Lines changed: 486 additions & 23 deletions

File tree

internal/proxy/bedrock.go

Lines changed: 247 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import (
88
"encoding/binary"
99
"encoding/hex"
1010
"encoding/json"
11+
"errors"
1112
"io"
13+
"log/slog"
1214
"net/http"
1315
"net/url"
1416
"strings"
@@ -180,12 +182,41 @@ func (s Server) claudeFableBedrockResponse(ctx context.Context, body []byte) (*h
180182
}
181183

182184
if stream && resp.StatusCode == http.StatusOK {
185+
first, peeked, err := peekBedrockStream(resp.Body)
186+
if err != nil || strings.EqualFold(first.messageType, "exception") {
187+
if s.Logger != nil {
188+
attrs := []any{"bedrock_source", sourceName, "region", region, "saw_message_stop", false}
189+
if first.exceptionType != "" {
190+
attrs = append(attrs, "exception_type", first.exceptionType, "message", bedrockLogPreview(first.payload))
191+
}
192+
if err != nil {
193+
attrs = append(attrs, "read_err", err)
194+
}
195+
s.Logger.Warn("claude-fable bedrock stream failed before first event", attrs...)
196+
}
197+
_ = resp.Body.Close()
198+
body := first.payload
199+
if len(body) == 0 {
200+
body = []byte(`{"type":"error","error":{"type":"api_error","message":"Bedrock stream failed before first event"}}`)
201+
}
202+
s.recordClaudeFableBedrockCost(started, region, http.StatusServiceUnavailable, bedrockUsage{}, false)
203+
return &http.Response{
204+
Status: "503 Service Unavailable",
205+
StatusCode: http.StatusServiceUnavailable,
206+
Proto: "HTTP/1.1",
207+
ProtoMajor: 1,
208+
ProtoMinor: 1,
209+
Header: http.Header{"Content-Type": {"application/json"}},
210+
Body: io.NopCloser(bytes.NewReader(body)),
211+
ContentLength: int64(len(body)),
212+
}, nil
213+
}
183214
pr, pw := io.Pipe()
184215
go func() {
185-
usage, haveUsage := transcodeBedrockToSSE(pw, resp.Body)
216+
result := transcodeBedrockToSSE(pw, io.MultiReader(bytes.NewReader(peeked), resp.Body), s.Logger, sourceName, region)
186217
_ = resp.Body.Close()
187218
_ = pw.Close()
188-
s.recordClaudeFableBedrockCost(started, region, http.StatusOK, usage, haveUsage)
219+
s.recordClaudeFableBedrockCost(started, region, http.StatusOK, result.Usage, result.HaveUsage)
189220
}()
190221
return &http.Response{
191222
Status: "200 OK",
@@ -401,18 +432,75 @@ func (cfg *BedrockConfig) onThrottle(sourceName, region, model string) {
401432
}
402433
}
403434

435+
type bedrockStreamResult struct {
436+
Usage bedrockUsage
437+
HaveUsage bool
438+
SawMessageStop bool
439+
ExceptionType string
440+
ReadErr error
441+
}
442+
443+
type bedrockFrame struct {
444+
payload []byte
445+
messageType string
446+
eventType string
447+
exceptionType string
448+
}
449+
450+
var errBedrockStreamEmpty = errors.New("bedrock stream ended before first event")
451+
404452
// transcodeBedrockToSSE converts a Bedrock invoke-with-response-stream body (AWS
405453
// event-stream framing wrapping Anthropic event JSON) into Anthropic Messages
406454
// SSE, which is what a Claude client on the OAuth path expects. It also extracts
407-
// token usage for cost tracking. w may be an http.ResponseWriter (flushed per
408-
// event) or a plain writer like an io.Pipe (each Write hands the event to the
409-
// reader directly).
410-
func transcodeBedrockToSSE(w io.Writer, src io.Reader) (bedrockUsage, bool) {
455+
// token usage for cost tracking and reports stream anomalies. w may be an
456+
// http.ResponseWriter (flushed per event) or a plain writer like an io.Pipe
457+
// (each Write hands the event to the reader directly).
458+
func transcodeBedrockToSSE(w io.Writer, src io.Reader, logger *slog.Logger, bedrockSource, region string) bedrockStreamResult {
411459
flusher, _ := w.(http.Flusher)
412460
var scanner bedrockFrameScanner
413-
var usage bedrockUsage
414-
var got bool
415-
emit := func(inner []byte) {
461+
var result bedrockStreamResult
462+
exceptionHandled := false
463+
finalize := func() bedrockStreamResult {
464+
result.HaveUsage = result.HaveUsage && !result.Usage.empty()
465+
return result
466+
}
467+
writeErrorEvent := func(errorType, message string) {
468+
payload, _ := json.Marshal(map[string]any{
469+
"type": "error",
470+
"error": map[string]string{
471+
"type": errorType,
472+
"message": message,
473+
},
474+
})
475+
_, _ = w.Write([]byte("event: error\ndata: "))
476+
_, _ = w.Write(payload)
477+
_, _ = w.Write([]byte("\n\n"))
478+
if flusher != nil {
479+
flusher.Flush()
480+
}
481+
}
482+
emit := func(frame bedrockFrame) {
483+
if exceptionHandled {
484+
return
485+
}
486+
if strings.EqualFold(frame.messageType, "exception") {
487+
result.ExceptionType = frame.exceptionType
488+
exceptionHandled = true
489+
if logger != nil {
490+
logger.Warn("claude-fable bedrock mid-stream exception", "exception_type", frame.exceptionType, "message", bedrockLogPreview(frame.payload), "bedrock_source", bedrockSource, "region", region, "saw_message_stop", result.SawMessageStop)
491+
}
492+
if strings.Contains(strings.ToLower(frame.exceptionType), "throttl") {
493+
writeErrorEvent("overloaded_error", "Bedrock throttled mid-stream")
494+
} else {
495+
message := "Bedrock stream error"
496+
if frame.exceptionType != "" {
497+
message += ": " + frame.exceptionType
498+
}
499+
writeErrorEvent("api_error", message)
500+
}
501+
return
502+
}
503+
inner := frame.payload
416504
var ev struct {
417505
Type string `json:"type"`
418506
Message struct {
@@ -423,14 +511,20 @@ func transcodeBedrockToSSE(w io.Writer, src io.Reader) (bedrockUsage, bool) {
423511
_ = json.Unmarshal(inner, &ev)
424512
switch ev.Type {
425513
case "message_start":
426-
usage.InputTokens = ev.Message.Usage.InputTokens
427-
usage.CacheReadTokens = ev.Message.Usage.CacheReadTokens
428-
usage.CacheWriteTokens = ev.Message.Usage.CacheWriteTokens
429-
got = true
514+
result.Usage.InputTokens = ev.Message.Usage.InputTokens
515+
result.Usage.CacheReadTokens = ev.Message.Usage.CacheReadTokens
516+
result.Usage.CacheWriteTokens = ev.Message.Usage.CacheWriteTokens
517+
result.HaveUsage = true
430518
case "message_delta":
431519
if ev.Usage.OutputTokens > 0 {
432-
usage.OutputTokens = ev.Usage.OutputTokens
433-
got = true
520+
result.Usage.OutputTokens = ev.Usage.OutputTokens
521+
result.HaveUsage = true
522+
}
523+
case "message_stop":
524+
result.SawMessageStop = true
525+
case "error":
526+
if logger != nil {
527+
logger.Warn("claude-fable bedrock in-band error", "exception_type", "", "message", bedrockLogPreview(inner), "bedrock_source", bedrockSource, "region", region, "saw_message_stop", result.SawMessageStop)
434528
}
435529
}
436530
eventType := ev.Type
@@ -449,19 +543,33 @@ func transcodeBedrockToSSE(w io.Writer, src io.Reader) (bedrockUsage, bool) {
449543
n, readErr := src.Read(buf)
450544
if n > 0 {
451545
scanner.feed(buf[:n], emit)
546+
if exceptionHandled {
547+
return finalize()
548+
}
452549
}
453550
if readErr != nil {
551+
if !result.SawMessageStop && !exceptionHandled {
552+
result.ReadErr = readErr
553+
if logger != nil {
554+
logger.Error("claude-fable bedrock stream truncated", "bedrock_source", bedrockSource, "region", region, "read_err", readErr, "saw_message_stop", result.SawMessageStop, "exception_type", "", "message", "")
555+
}
556+
writeErrorEvent("api_error", "Bedrock stream interrupted")
557+
} else if readErr != io.EOF {
558+
result.ReadErr = readErr
559+
}
454560
break
455561
}
456562
}
457-
return usage, got && !usage.empty()
563+
return finalize()
458564
}
459565

460566
// bedrockFrameScanner parses AWS event-stream frames incrementally and yields
461-
// each frame's decoded inner payload (the Anthropic event JSON).
567+
// each frame's decoded inner payload (the Anthropic event JSON) with AWS
568+
// event-stream metadata. Exception frames carry a raw JSON error payload instead
569+
// of a {"bytes": "..."} wrapper.
462570
type bedrockFrameScanner struct{ buf []byte }
463571

464-
func (s *bedrockFrameScanner) feed(data []byte, emit func([]byte)) {
572+
func (s *bedrockFrameScanner) feed(data []byte, emit func(bedrockFrame)) {
465573
s.buf = append(s.buf, data...)
466574
for {
467575
if len(s.buf) < 12 {
@@ -478,20 +586,139 @@ func (s *bedrockFrameScanner) feed(data []byte, emit func([]byte)) {
478586
}
479587
payloadStart := 12 + headersLen
480588
payloadEnd := total - 4
589+
headers := map[string]string(nil)
590+
if headersLen >= 0 && 12+headersLen <= payloadEnd {
591+
headers, _ = parseBedrockEventHeaders(s.buf[12 : 12+headersLen])
592+
} else {
593+
payloadStart = 12
594+
}
481595
if payloadStart <= payloadEnd && payloadEnd <= len(s.buf) {
596+
frame := bedrockFrame{
597+
messageType: headers[":message-type"],
598+
eventType: headers[":event-type"],
599+
exceptionType: headers[":exception-type"],
600+
}
601+
payload := s.buf[payloadStart:payloadEnd]
602+
if strings.EqualFold(frame.messageType, "exception") {
603+
frame.payload = append([]byte(nil), payload...)
604+
emit(frame)
605+
s.buf = s.buf[total:]
606+
continue
607+
}
482608
var wrap struct {
483609
Bytes string `json:"bytes"`
484610
}
485-
if json.Unmarshal(s.buf[payloadStart:payloadEnd], &wrap) == nil && wrap.Bytes != "" {
611+
if json.Unmarshal(payload, &wrap) == nil && wrap.Bytes != "" {
486612
if decoded, err := base64.StdEncoding.DecodeString(wrap.Bytes); err == nil {
487-
emit(decoded)
613+
frame.payload = decoded
614+
emit(frame)
488615
}
489616
}
490617
}
491618
s.buf = s.buf[total:]
492619
}
493620
}
494621

622+
func parseBedrockEventHeaders(headers []byte) (map[string]string, bool) {
623+
out := make(map[string]string)
624+
for len(headers) > 0 {
625+
if len(headers) < 2 {
626+
return nil, false
627+
}
628+
nameLen := int(headers[0])
629+
headers = headers[1:]
630+
if len(headers) < nameLen+1 {
631+
return nil, false
632+
}
633+
name := string(headers[:nameLen])
634+
headers = headers[nameLen:]
635+
valueType := headers[0]
636+
headers = headers[1:]
637+
switch valueType {
638+
case 0, 1:
639+
case 2:
640+
if len(headers) < 1 {
641+
return nil, false
642+
}
643+
headers = headers[1:]
644+
case 3:
645+
if len(headers) < 2 {
646+
return nil, false
647+
}
648+
headers = headers[2:]
649+
case 4:
650+
if len(headers) < 4 {
651+
return nil, false
652+
}
653+
headers = headers[4:]
654+
case 5, 8:
655+
if len(headers) < 8 {
656+
return nil, false
657+
}
658+
headers = headers[8:]
659+
case 6, 7:
660+
if len(headers) < 2 {
661+
return nil, false
662+
}
663+
valueLen := int(binary.BigEndian.Uint16(headers[:2]))
664+
headers = headers[2:]
665+
if len(headers) < valueLen {
666+
return nil, false
667+
}
668+
if valueType == 7 {
669+
out[name] = string(headers[:valueLen])
670+
}
671+
headers = headers[valueLen:]
672+
case 9:
673+
if len(headers) < 16 {
674+
return nil, false
675+
}
676+
headers = headers[16:]
677+
default:
678+
return nil, false
679+
}
680+
}
681+
return out, true
682+
}
683+
684+
func peekBedrockStream(src io.Reader) (bedrockFrame, []byte, error) {
685+
var scanner bedrockFrameScanner
686+
var first bedrockFrame
687+
var got bool
688+
var peeked bytes.Buffer
689+
emit := func(frame bedrockFrame) {
690+
if !got {
691+
first = frame
692+
got = true
693+
}
694+
}
695+
buf := make([]byte, 32*1024)
696+
for !got {
697+
n, readErr := src.Read(buf)
698+
if n > 0 {
699+
peeked.Write(buf[:n])
700+
scanner.feed(buf[:n], emit)
701+
}
702+
if got {
703+
break
704+
}
705+
if readErr != nil {
706+
if readErr == io.EOF {
707+
return bedrockFrame{}, peeked.Bytes(), errBedrockStreamEmpty
708+
}
709+
return bedrockFrame{}, peeked.Bytes(), readErr
710+
}
711+
}
712+
return first, peeked.Bytes(), nil
713+
}
714+
715+
func bedrockLogPreview(payload []byte) string {
716+
if len(payload) > 256 {
717+
payload = payload[:256]
718+
}
719+
return string(payload)
720+
}
721+
495722
func (s Server) handleBedrockCost(w http.ResponseWriter, r *http.Request) {
496723
path := ""
497724
if s.Bedrock != nil {

internal/proxy/bedrock_cost_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,15 +107,15 @@ func TestTranscodeBedrockToSSE(t *testing.T) {
107107
stream := append(append(append([]byte{}, start...), delta...), stop...)
108108

109109
rec := httptest.NewRecorder()
110-
usage, ok := transcodeBedrockToSSE(rec, bytes.NewReader(stream))
110+
result := transcodeBedrockToSSE(rec, bytes.NewReader(stream), nil, "", "")
111111
out := rec.Body.String()
112112
if !strings.Contains(out, "event: message_start\ndata: {") {
113113
t.Fatalf("missing message_start SSE frame:\n%s", out)
114114
}
115115
if !strings.Contains(out, "event: message_stop\ndata: {") {
116116
t.Fatalf("missing message_stop SSE frame:\n%s", out)
117117
}
118-
if !ok || usage.InputTokens != 10 || usage.OutputTokens != 7 {
119-
t.Fatalf("usage = %+v ok=%v, want in=10 out=7", usage, ok)
118+
if !result.HaveUsage || result.Usage.InputTokens != 10 || result.Usage.OutputTokens != 7 {
119+
t.Fatalf("usage = %+v ok=%v, want in=10 out=7", result.Usage, result.HaveUsage)
120120
}
121121
}

0 commit comments

Comments
 (0)