package bot import ( "encoding/json" "errors" "net/http" "net/http/httptest" "sync" "testing" ) // The client retries transient failures (a dropped connection or a 5xx) and // succeeds once the API responds — so a single "unexpected EOF" on the long // build call no longer fails the whole build. func TestAnthropicRetriesTransient(t *testing.T) { var mu sync.Mutex n := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mu.Lock() n++ attempt := n mu.Unlock() switch attempt { case 1: // Drop the connection mid-response → the client sees a transport // error (like unexpected EOF). hj, ok := w.(http.Hijacker) if !ok { t.Fatal("no hijacker") } conn, _, _ := hj.Hijack() conn.Close() case 2: w.WriteHeader(http.StatusServiceUnavailable) w.Write([]byte("overloaded")) default: w.Write([]byte(`{"content":[{"type":"text","text":"ok"}],"stop_reason":"end_turn"}`)) } })) defer srv.Close() c := &anthropicClient{key: "k", base: srv.URL, http: srv.Client()} resp, err := c.create(anthRequest{Model: "m", MaxTokens: 10, Messages: []anthMessage{{Role: "user", Content: "hi"}}}) if err != nil { t.Fatalf("create should have retried to success: %v", err) } if resp.text() != "ok" { t.Fatalf("bad reply: %q", resp.text()) } if n != 3 { t.Fatalf("expected 3 attempts (drop, 503, ok), got %d", n) } } // createStream assembles the response from SSE deltas — text and tool_use // accumulate across content blocks, and a request that ends with message_stop // succeeds. func TestAnthropicStreamAssembles(t *testing.T) { stream := "event: message_start\n" + `data: {"type":"message_start"}` + "\n\n" + "event: content_block_start\n" + `data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}` + "\n\n" + "event: content_block_delta\n" + `data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello "}}` + "\n\n" + "event: content_block_delta\n" + `data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"world"}}` + "\n\n" + "event: content_block_stop\n" + `data: {"type":"content_block_stop","index":0}` + "\n\n" + "event: content_block_start\n" + `data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"t1","name":"do_it","input":{}}}` + "\n\n" + "event: content_block_delta\n" + `data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"x\":"}}` + "\n\n" + "event: content_block_delta\n" + `data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"1}"}}` + "\n\n" + "event: message_delta\n" + `data: {"type":"message_delta","delta":{"stop_reason":"tool_use"}}` + "\n\n" + "event: message_stop\n" + `data: {"type":"message_stop"}` + "\n\n" var gotStream bool srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body struct { Stream bool `json:"stream"` } json.NewDecoder(r.Body).Decode(&body) gotStream = body.Stream w.Header().Set("Content-Type", "text/event-stream") w.Write([]byte(stream)) })) defer srv.Close() c := &anthropicClient{key: "k", base: srv.URL, http: srv.Client()} resp, err := c.createStream(anthRequest{Model: "m", MaxTokens: 10, Messages: []anthMessage{{Role: "user", Content: "hi"}}}) if err != nil { t.Fatalf("createStream: %v", err) } if !gotStream { t.Fatal("request did not set stream:true") } if resp.text() != "Hello world" { t.Fatalf("text not assembled: %q", resp.text()) } if resp.StopReason != "tool_use" { t.Fatalf("stop reason: %q", resp.StopReason) } if len(resp.Content) != 2 || resp.Content[1].Type != "tool_use" || resp.Content[1].Name != "do_it" { t.Fatalf("tool_use block missing: %+v", resp.Content) } if v, _ := resp.Content[1].Input["x"].(float64); v != 1 { t.Fatalf("tool_use input not assembled: %+v", resp.Content[1].Input) } } // A stream cut before message_stop is transient: the client retries and succeeds // on the next attempt — the streaming twin of the EOF-retry behaviour. func TestAnthropicStreamRetriesCutStream(t *testing.T) { full := "event: content_block_start\n" + `data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}` + "\n\n" + "event: content_block_delta\n" + `data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ok"}}` + "\n\n" + "event: message_stop\n" + `data: {"type":"message_stop"}` + "\n\n" var mu sync.Mutex n := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mu.Lock() n++ attempt := n mu.Unlock() if attempt == 1 { // Some deltas, then drop the connection with no message_stop. hj, _ := w.(http.Hijacker) conn, buf, _ := hj.Hijack() buf.WriteString("HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\n\r\n") buf.WriteString("event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n") buf.Flush() conn.Close() return } w.Header().Set("Content-Type", "text/event-stream") w.Write([]byte(full)) })) defer srv.Close() c := &anthropicClient{key: "k", base: srv.URL, http: srv.Client()} resp, err := c.createStream(anthRequest{Model: "m", MaxTokens: 10, Messages: []anthMessage{{Role: "user", Content: "hi"}}}) if err != nil { t.Fatalf("createStream should have retried past the cut: %v", err) } if resp.text() != "ok" { t.Fatalf("bad reply: %q", resp.text()) } if n != 2 { t.Fatalf("expected 2 attempts (cut, ok), got %d", n) } } // A deterministic 4xx (bad request / auth) is NOT retried — it would just fail // the same way, so the caller learns immediately. func TestAnthropicDoesNotRetry4xx(t *testing.T) { var mu sync.Mutex n := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mu.Lock() n++ mu.Unlock() w.WriteHeader(http.StatusBadRequest) w.Write([]byte(`{"error":{"message":"bad"}}`)) })) defer srv.Close() c := &anthropicClient{key: "k", base: srv.URL, http: srv.Client()} _, err := c.create(anthRequest{Model: "m", MaxTokens: 10, Messages: []anthMessage{{Role: "user", Content: "hi"}}}) if err == nil { t.Fatal("expected an error") } var he *anthHTTPError if !errors.As(err, &he) || he.status != http.StatusBadRequest { t.Fatalf("expected a 400 anthHTTPError, got %v", err) } mu.Lock() defer mu.Unlock() if n != 1 { t.Fatalf("a 4xx must not be retried; got %d attempts", n) } }