Go で Claude API を呼び出すコードを書いたとき、最初に気づく「やりにくさ」があります。
ストリーミングレスポンスを goroutine で処理しようとすると、context が早期にキャンセルされてパニックになります。Tool Use の並行実行を試みると、レート制限エラーが散発します。Gin のハンドラーに組み込もうとすると、SSE の flusher 設定でつまずきます。
これらは Go 特有のパターンで、Python や TypeScript の記事からは学べません。いくつかの本番プロジェクトで Claude API を Go に統合してきた中でぶつかった壁を、動作確認済みのコードとともに整理したのがこの記事です。
Anthropic の公式 Go SDK(anthropic-sdk-go)は 2024 年末にリリースされ、現在も活発に開発が続いています。Python SDK ほど情報が多くないため、Go バックエンドエンジニアが実装で詰まりやすい状況が続いています。
Anthropic Go SDK のセットアップと初回呼び出し
まず基本から始めましょう。anthropic-sdk-go の依存追加と、確実に動く最初の呼び出しコードです。
go get github.com/anthropics/anthropic-sdk-go
// main.go
package main
import (
" context "
" fmt "
" log "
" os "
" github.com/anthropics/anthropic-sdk-go "
" github.com/anthropics/anthropic-sdk-go/option "
)
func main () {
// APIキーは環境変数から取得(ハードコードは絶対にしない)
client := anthropic. NewClient (
option. WithAPIKey (os. Getenv ( "ANTHROPIC_API_KEY" )),
)
ctx := context. Background ()
msg, err := client.Messages. New (ctx, anthropic . MessageNewParams {
Model: anthropic. F (anthropic.ModelClaude_Sonnet_4_6),
MaxTokens: anthropic. F ( int64 ( 1024 )),
Messages: anthropic. F ([] anthropic . MessageParam {
anthropic. NewUserMessage (anthropic. NewTextBlock ( "Go言語の並行処理の特徴を3行で教えてください" )),
}),
})
if err != nil {
log. Fatalf ( "API呼び出し失敗: %v " , err)
}
for _, block := range msg.Content {
if block.Type == anthropic.ContentBlockTypeText {
fmt. Println (block.Text)
}
}
}
ポイント : anthropic.F() は Option 型でラップするヘルパー関数です。Go SDK は「フィールドが設定されているかどうか」を明示的に管理するため、nil と「未設定」を区別します。最初は冗長に見えますが、APIパラメータの省略・必須を型で明確にするための設計になっています。
System Prompt と会話履歴の管理
実際のアプリでは会話履歴を保持する必要があります。Go では構造体でステートを管理するのが自然な設計です。
// conversation.go
package claude
import (
" context "
" fmt "
anthropic " github.com/anthropics/anthropic-sdk-go "
)
// ConversationSession は1会話のステートを管理します
type ConversationSession struct {
client * anthropic . Client
systemText string
history [] anthropic . MessageParam
model anthropic . Model
}
func NewConversationSession ( client * anthropic . Client , systemPrompt string ) * ConversationSession {
return & ConversationSession {
client: client,
systemText: systemPrompt,
history: make ([] anthropic . MessageParam , 0 ),
model: anthropic.ModelClaude_Sonnet_4_6,
}
}
// Send はユーザーメッセージを送信し、アシスタントの返答を返します
func ( s * ConversationSession ) Send ( ctx context . Context , userMsg string ) ( string , error ) {
// ユーザーメッセージを履歴に追加
s.history = append (s.history, anthropic. NewUserMessage (
anthropic. NewTextBlock (userMsg),
))
params := anthropic . MessageNewParams {
Model: anthropic. F (s.model),
MaxTokens: anthropic. F ( int64 ( 2048 )),
Messages: anthropic. F (s.history),
}
if s.systemText != "" {
params.System = anthropic. F ([] anthropic . TextBlockParam {
anthropic. NewTextBlock (s.systemText),
})
}
resp, err := s.client.Messages. New (ctx, params)
if err != nil {
// エラー時は履歴をロールバック(アシスタント応答なしで終わると次回リクエストが壊れる)
s.history = s.history[: len (s.history) - 1 ]
return "" , fmt. Errorf ( "API呼び出し失敗: %w " , err)
}
var result string
for _, block := range resp.Content {
if block.Type == anthropic.ContentBlockTypeText {
result += block.Text
}
}
s.history = append (s.history, anthropic. NewAssistantMessage (
anthropic. NewTextBlock (result),
))
return result, nil
}
エラー時に履歴をロールバックする実装が重要です。API 呼び出し失敗後にユーザーメッセージだけ履歴に残ると、次の呼び出しで「アシスタントメッセージが欠落している」エラーが発生します。
Streaming レスポンスの正しい実装
Streaming は Go で最もハマりやすい部分です。よくある間違いと正しい実装を並べてみます。
よくある間違い: goroutine リーク
// BAD: これはgoroutineリークを起こします
func badStreaming ( client * anthropic . Client ) {
ctx := context. Background ()
stream := client.Messages. NewStreaming (ctx, anthropic . MessageNewParams {
Model: anthropic. F (anthropic.ModelClaude_Sonnet_4_6),
MaxTokens: anthropic. F ( int64 ( 1024 )),
Messages: anthropic. F ([] anthropic . MessageParam {
anthropic. NewUserMessage (anthropic. NewTextBlock ( "Hello" )),
}),
})
go func () {
for stream. Next () {
event := stream. Current ()
_ = event
}
// stream.Close()が呼ばれないとリーク発生
}()
// この関数が返った後、goroutineは何も受け取る先がなく宙ぶらりんになる
}
// GOOD: 正しいStreaming実装
func goodStreaming ( ctx context . Context , client * anthropic . Client , userMsg string , output chan<- string ) error {
stream := client.Messages. NewStreaming (ctx, anthropic . MessageNewParams {
Model: anthropic. F (anthropic.ModelClaude_Sonnet_4_6),
MaxTokens: anthropic. F ( int64 ( 2048 )),
Messages: anthropic. F ([] anthropic . MessageParam {
anthropic. NewUserMessage (anthropic. NewTextBlock (userMsg)),
}),
})
defer stream. Close () // 必ずdeferでCloseする
for stream. Next () {
select {
case <- ctx. Done ():
return ctx. Err ()
default :
}
event := stream. Current ()
switch event := event. AsUnion ().( type ) {
case anthropic . ContentBlockDeltaEvent :
if delta, ok := event.Delta. AsUnion ().( anthropic . TextDelta ); ok {
select {
case output <- delta.Text:
case <- ctx. Done ():
return ctx. Err ()
}
}
}
}
if err := stream. Err (); err != nil {
return fmt. Errorf ( "ストリームエラー: %w " , err)
}
return nil
}
defer stream.Close() が必須です。また、channel への送信時にも ctx.Done() を監視することで、クライアント切断時に goroutine が正しく終了するようになります。
Gin での SSE ストリーミングエンドポイント
// handler/stream.go
package handler
import (
" fmt "
" net/http "
" github.com/gin-gonic/gin "
)
func ( h * Handler ) StreamChat ( c * gin . Context ) {
var req struct {
Message string `json:"message" binding:"required"`
}
if err := c. ShouldBindJSON ( & req); err != nil {
c. JSON (http.StatusBadRequest, gin . H { "error" : err. Error ()})
return
}
// SSEヘッダーの設定
c. Header ( "Content-Type" , "text/event-stream" )
c. Header ( "Cache-Control" , "no-cache" )
c. Header ( "Connection" , "keep-alive" )
c. Header ( "X-Accel-Buffering" , "no" ) // nginxのバッファリングを無効化(重要)
flusher, ok := c.Writer.( http . Flusher )
if ! ok {
c. JSON (http.StatusInternalServerError, gin . H { "error" : "SSE非対応環境です" })
return
}
ctx := c.Request. Context ()
tokenCh := make ( chan string , 10 )
errCh := make ( chan error , 1 )
go func () {
defer close (tokenCh)
err := h.claude. StreamMessage (ctx, req.Message, tokenCh)
errCh <- err
}()
for {
select {
case token, ok := <- tokenCh:
if ! ok {
fmt. Fprintf (c.Writer, "data: [DONE] \n\n " )
flusher. Flush ()
return
}
fmt. Fprintf (c.Writer, "data: %s\n\n " , token)
flusher. Flush ()
case err := <- errCh:
if err != nil {
fmt. Fprintf (c.Writer, "event: error \n data: %s\n\n " , err. Error ())
flusher. Flush ()
}
return
case <- ctx. Done ():
return
}
}
}
X-Accel-Buffering: no ヘッダーは nginx を使っている場合に忘れがちですが、これがないとレスポンスがバッファリングされてリアルタイム性が失われます。
Tool Use の並行実装
Tool Use(関数呼び出し)は Claude が複数のツールを同時に呼び出せるため、並行実行の設計が重要になります。
ツール定義と実行エンジン
// tools/engine.go
package tools
import (
" context "
" encoding/json "
" fmt "
" sync "
anthropic " github.com/anthropics/anthropic-sdk-go "
" golang.org/x/sync/errgroup "
)
// ToolFunc はツール実行関数の型です
type ToolFunc func ( ctx context . Context , input json . RawMessage ) ( string , error )
// ToolEngine はツールの登録と並行実行を管理します
type ToolEngine struct {
tools map [ string ] ToolFunc
defs [] anthropic . ToolParam
mu sync . RWMutex
}
func NewToolEngine () * ToolEngine {
return & ToolEngine {
tools: make ( map [ string ] ToolFunc ),
defs: make ([] anthropic . ToolParam , 0 ),
}
}
// Register はツールを登録します
func ( e * ToolEngine ) Register ( name , description string , inputSchema interface {}, fn ToolFunc ) {
e.mu. Lock ()
defer e.mu. Unlock ()
schemaBytes, _ := json. Marshal (inputSchema)
e.tools[name] = fn
e.defs = append (e.defs, anthropic . ToolParam {
Name: anthropic. F (name),
Description: anthropic. F (description),
InputSchema: anthropic. F ( anthropic . ToolInputSchemaParam {
Type: anthropic. F (anthropic.ToolInputSchemaTypeObject),
Properties: anthropic. Raw [ interface {}](schemaBytes),
}),
})
}
// Definitions はAPI呼び出し用のツール定義リストを返します
func ( e * ToolEngine ) Definitions () [] anthropic . ToolParam {
e.mu. RLock ()
defer e.mu. RUnlock ()
return e.defs
}
// ExecuteParallel は複数のツール呼び出しを並行実行します
func ( e * ToolEngine ) ExecuteParallel ( ctx context . Context , calls [] anthropic . ToolUseBlock ) ([] anthropic . ToolResultBlockParam , error ) {
e.mu. RLock ()
defer e.mu. RUnlock ()
results := make ([] anthropic . ToolResultBlockParam , len (calls))
eg, ctx := errgroup. WithContext (ctx)
for i, call := range calls {
i, call := i, call // ループ変数のキャプチャ(Go 1.21以前では必須)
eg. Go ( func () error {
fn, ok := e.tools[call.Name]
if ! ok {
results[i] = anthropic. NewToolResultBlock (
call.ID,
fmt. Sprintf ( "ツール ' %s ' が見つかりません" , call.Name),
true ,
)
return nil // ツール未登録はerrとして伝播させない
}
output, err := fn (ctx, call.Input)
if err != nil {
// ツール実行エラーはClaudeに知らせる(goroutine全体は止めない)
results[i] = anthropic. NewToolResultBlock (call.ID, err. Error (), true )
return nil
}
results[i] = anthropic. NewToolResultBlock (call.ID, output, false )
return nil
})
}
if err := eg. Wait (); err != nil {
return nil , err
}
return results, nil
}
重要な設計判断として、ツール実行の失敗は errgroup のエラーとして伝播させず、ToolResultBlock の isError: true フラグで Claude に知らせる方式にしています。これにより、1つのツール失敗で他のツールの結果が失われる事態を防げます。
Tool Use を使ったエージェントループ
// agent/loop.go
package agent
import (
" context "
" fmt "
anthropic " github.com/anthropics/anthropic-sdk-go "
" your-module/tools "
)
// Run はTool Use を含むエージェントの完全なループを実行します
func Run ( ctx context . Context , client * anthropic . Client , engine * tools . ToolEngine , userMsg string ) ( string , error ) {
messages := [] anthropic . MessageParam {
anthropic. NewUserMessage (anthropic. NewTextBlock (userMsg)),
}
const maxIterations = 10 // 無限ループ防止
for i := 0 ; i < maxIterations; i ++ {
resp, err := client.Messages. New (ctx, anthropic . MessageNewParams {
Model: anthropic. F (anthropic.ModelClaude_Sonnet_4_6),
MaxTokens: anthropic. F ( int64 ( 4096 )),
Tools: anthropic. F (engine. Definitions ()),
Messages: anthropic. F (messages),
})
if err != nil {
return "" , fmt. Errorf ( "反復 %d : APIエラー: %w " , i + 1 , err)
}
// アシスタントメッセージを履歴に追加
messages = append (messages, anthropic. NewAssistantMessage (resp.Content ... ))
if resp.StopReason == anthropic.StopReasonEndTurn {
for _, block := range resp.Content {
if block.Type == anthropic.ContentBlockTypeText {
return block.Text, nil
}
}
return "" , nil
}
if resp.StopReason != anthropic.StopReasonToolUse {
return "" , fmt. Errorf ( "予期しないstop_reason: %s " , resp.StopReason)
}
// Tool Use ブロックを収集して並行実行
var toolCalls [] anthropic . ToolUseBlock
for _, block := range resp.Content {
if block.Type == anthropic.ContentBlockTypeToolUse {
toolCalls = append (toolCalls, block. AsToolUseBlock ())
}
}
toolResults, err := engine. ExecuteParallel (ctx, toolCalls)
if err != nil {
return "" , fmt. Errorf ( "ツール実行エラー: %w " , err)
}
resultBlocks := make ([] anthropic . ContentBlockParamUnion , len (toolResults))
for j, r := range toolResults {
resultBlocks[j] = r
}
messages = append (messages, anthropic. NewUserMessage (resultBlocks ... ))
}
return "" , fmt. Errorf ( "最大反復回数( %d )に達しました" , maxIterations)
}
maxIterations の上限設定は本番で必須です。ループを終了させる条件が想定外にネストすると、無限ループでコストが爆発します。
レート制限と並行リクエストの制御
Go のマイクロサービスでは、複数のユーザーリクエストが同時に Claude API を叩くシナリオが日常的です。golang.org/x/time/rate を使ったレート制限の実装を示します。
// ratelimit/limiter.go
package ratelimit
import (
" context "
" fmt "
" time "
" golang.org/x/time/rate "
)
// ClaudeLimiter はClaude API用のレート制限器です
type ClaudeLimiter struct {
reqLimiter * rate . Limiter
tokenLimiter * rate . Limiter
}
// NewClaudeLimiter はレート制限器を生成します
// reqPerMin: リクエスト数/分、tokensPerMin: トークン数/分
func NewClaudeLimiter ( reqPerMin , tokensPerMin int ) * ClaudeLimiter {
return & ClaudeLimiter {
// rate.Every で1分間の均等配分を設定
reqLimiter: rate. NewLimiter (rate. Every (time.Minute / time. Duration (reqPerMin)), reqPerMin / 10 ),
tokenLimiter: rate. NewLimiter (rate. Limit ( float64 (tokensPerMin)) / 60 , tokensPerMin / 10 ),
}
}
// Wait はレート制限を待機します(contextキャンセルに対応)
func ( l * ClaudeLimiter ) Wait ( ctx context . Context , estimatedTokens int ) error {
if err := l.reqLimiter. Wait (ctx); err != nil {
return fmt. Errorf ( "リクエスト制限待機中にキャンセル: %w " , err)
}
if err := l.tokenLimiter. WaitN (ctx, estimatedTokens); err != nil {
return fmt. Errorf ( "トークン制限待機中にキャンセル: %w " , err)
}
return nil
}
重要なのは、tokensPerMin の設定値です。Claude Sonnet 4.6 の無料枠は約 40,000 tokens/min ですが、実際には入力トークン+出力トークンの合計で計算されます。推定トークン数は入力文字数の約 1.5 倍を見ておくと安全です。
よくある落とし穴と解決策
Go + Claude API の組み合わせで実際に遭遇した問題をまとめます。
落とし穴1: context.WithTimeout の配置場所
// BAD: タイムアウトが短すぎてStreaming中に切れる
func badTimeout ( client * anthropic . Client ) {
ctx, cancel := context. WithTimeout (context. Background (), 5 * time.Second)
defer cancel ()
// Streamingは5秒以上かかることが多い → タイムアウトで失敗する
stream := client.Messages. NewStreaming (ctx, anthropic . MessageNewParams {
Model: anthropic. F (anthropic.ModelClaude_Sonnet_4_6),
MaxTokens: anthropic. F ( int64 ( 2048 )),
Messages: anthropic. F ([] anthropic . MessageParam {}),
})
defer stream. Close ()
}
// GOOD: Streaming全体に余裕のあるタイムアウトを設定する
func goodTimeout ( client * anthropic . Client , userMsg string ) {
ctx, cancel := context. WithTimeout (context. Background (), 5 * time.Minute)
defer cancel ()
stream := client.Messages. NewStreaming (ctx, anthropic . MessageNewParams {
Model: anthropic. F (anthropic.ModelClaude_Sonnet_4_6),
MaxTokens: anthropic. F ( int64 ( 2048 )),
Messages: anthropic. F ([] anthropic . MessageParam {
anthropic. NewUserMessage (anthropic. NewTextBlock (userMsg)),
}),
})
defer stream. Close ()
// ...
}
落とし穴2: 型アサーションでのパニック
Go SDK のレスポンスは Union 型が多用されています。.(type) の switch 文で必ず全ケースを処理してください。
// BAD: 型アサーションの失敗でpanicになるパターン
block := resp.Content[ 0 ]
textBlock := block. AsTextBlock () // ToolUseBlockだとpanic
// GOOD: 安全な処理
for _, block := range resp.Content {
switch block.Type {
case anthropic.ContentBlockTypeText:
fmt. Println (block.Text)
case anthropic.ContentBlockTypeToolUse:
toolBlock := block. AsToolUseBlock ()
fmt. Printf ( "Tool: %s , ID: %s\n " , toolBlock.Name, toolBlock.ID)
default :
// 将来の新ブロックタイプへの対応
fmt. Printf ( "未知のブロックタイプ: %s\n " , block.Type)
}
}
落とし穴3: nginx のアップストリームタイムアウト
nginx や ELB でプロキシしている場合、アップストリームのタイムアウトがデフォルト 60 秒に設定されていることがあります。長い回答の Streaming が 60 秒を超えると、中断されてしまいます。
# nginx.conf — Streamingエンドポイント専用設定
location /api/stream {
proxy_pass http://backend;
proxy_read_timeout 300s ;
proxy_send_timeout 300s ;
proxy_buffering off ;
proxy_cache off ;
chunked_transfer_encoding on ;
}
落とし穴4: ループ変数のキャプチャ(Go 1.21以前)
// BAD: Go 1.21以前では全goroutineが最後のiとcallを参照してしまう
for i, call := range calls {
go func () {
process (i, call) // 全goroutineがループ最終値を参照
}()
}
// GOOD: ループ変数をローカル変数に代入してキャプチャ
for i, call := range calls {
i, call := i, call // シャドウイングでキャプチャ
go func () {
process (i, call) // 各goroutineが正しい値を参照
}()
}
Go 1.22 以降ではデフォルトで修正されましたが、古いバージョンを使っているプロジェクトでは今でも遭遇するバグです。
Kubernetes でのグレースフルシャットダウン
本番環境では Pod の再起動時に進行中の Streaming リクエストを安全に終了させる必要があります。
// main.go — グレースフルシャットダウンの実装
func main () {
cfg, err := config. Load ()
if err != nil {
log. Fatalf ( "設定読み込み失敗: %v " , err)
}
router := setupRouter (cfg)
srv := & http . Server {
Addr: ":8080" ,
Handler: router,
ReadTimeout: 30 * time.Second,
WriteTimeout: 10 * time.Minute, // Streaming対応のため長めに設定
IdleTimeout: 120 * time.Second,
}
quit := make ( chan os . Signal , 1 )
signal. Notify (quit, syscall.SIGINT, syscall.SIGTERM)
go func () {
if err := srv. ListenAndServe (); err != nil && err != http.ErrServerClosed {
log. Fatalf ( "サーバー起動失敗: %v " , err)
}
}()
log. Printf ( "サーバー起動: : %d " , 8080 )
<- quit
log. Println ( "シャットダウン開始(進行中リクエストの完了を待機中)" )
// 進行中のStreamingリクエストに最大90秒の余裕を与える
shutdownCtx, shutdownCancel := context. WithTimeout (context. Background (), 90 * time.Second)
defer shutdownCancel ()
if err := srv. Shutdown (shutdownCtx); err != nil {
log. Printf ( "強制シャットダウン: %v " , err)
}
log. Println ( "シャットダウン完了" )
}
Kubernetes の terminationGracePeriodSeconds をこの値(90秒)より大きく設定することも必要です。デフォルトの 30 秒では Streaming の途中で Pod が強制終了されてしまいます。
# deployment.yaml
spec :
terminationGracePeriodSeconds : 120 # shutdownTimeout(90s) + 余裕
containers :
- name : claude-service
livenessProbe :
httpGet :
path : /health
port : 8080
initialDelaySeconds : 10
periodSeconds : 30
readinessProbe :
httpGet :
path : /health
port : 8080
initialDelaySeconds : 5
periodSeconds : 10
インターフェースを活用したテスト設計
本番コードをテスト可能にするには、Claude クライアントをインターフェースとして定義する点が肝心です。
// claude/interface.go
package claude
import (
" context "
anthropic " github.com/anthropics/anthropic-sdk-go "
)
// MessagesService はClaude Messages APIのインターフェースです
type MessagesService interface {
New ( ctx context . Context , params anthropic . MessageNewParams ) ( * anthropic . Message , error )
}
// MockMessagesService はテスト用のモック実装です
type MockMessagesService struct {
Responses [] string // 順番に返す応答リスト
Errors [] error
callCount int
}
func ( m * MockMessagesService ) New ( ctx context . Context , params anthropic . MessageNewParams ) ( * anthropic . Message , error ) {
idx := m.callCount
m.callCount ++
if idx < len (m.Errors) && m.Errors[idx] != nil {
return nil , m.Errors[idx]
}
response := "デフォルト応答"
if idx < len (m.Responses) {
response = m.Responses[idx]
}
return & anthropic . Message {
Content: [] anthropic . ContentBlock {
{Type: anthropic.ContentBlockTypeText, Text: response},
},
StopReason: anthropic.StopReasonEndTurn,
}, nil
}
// agent_test.go の例
func TestAgentLoop ( t * testing . T ) {
mock := & MockMessagesService {
Responses: [] string { "最初の応答" , "最終的な回答" },
}
result, err := RunWithService (context. Background (), mock, "テスト質問" )
if err != nil {
t. Fatalf ( "予期しないエラー: %v " , err)
}
if result == "" {
t. Error ( "空の応答が返された" )
}
}
実際の API を叩くインテグレーションテストは go test -tags=integration で分離することをおすすめします。CI では -short フラグで API テストをスキップできるよう設計しておくと、開発速度が上がります。
goroutine が漏れていないことを、テストで確かめる
先ほどの BAD と GOOD の差は、並べて読めば明らかです。ところが数か月後、ストリームの途中で早期 return する分岐が一行追加された瞬間に、defer stream.Close() を書き忘れたのと実質同じ状態が静かに戻ってきます。私自身、これをレビューで毎回見抜ける自信がありませんでした。
そこで、目視ではなくテストで守るようにしています。
確かめたいのは二つです。読者がページを閉じたとき(= context がキャンセルされたとき)に goodStreaming が速やかに戻ること。そしてテストプロセスの終了時点で、goroutine が一つも残っていないこと。
実 API を叩くと応答長やレイテンシに左右されて不安定になるため、終わらない SSE を返すサーバを httptest で立て、SDK の baseURL をそちらへ向けます。API キーが不要になるので、CI でもそのまま回せます。
// claude/stream_leak_test.go
package claude
import (
" context "
" errors "
" fmt "
" net/http "
" net/http/httptest "
" testing "
" time "
" github.com/anthropics/anthropic-sdk-go "
" github.com/anthropics/anthropic-sdk-go/option "
" go.uber.org/goleak "
)
// パッケージ内の全テスト終了後に、残存goroutineがあれば失敗させます
func TestMain ( m * testing . M ) {
goleak. VerifyTestMain (m)
}
// 終わらないSSEを返すサーバ。「長い応答の途中で読者が離脱する」状況を再現します
func endlessStreamServer ( t * testing . T ) * httptest . Server {
t. Helper ()
return httptest. NewServer (http. HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
w. Header (). Set ( "Content-Type" , "text/event-stream" )
flusher, ok := w.( http . Flusher )
if ! ok {
t. Error ( "http.Flusher が利用できません" )
return
}
fmt. Fprint (w, "event: message_start \n " +
`data: {"type":"message_start","message":{"id":"msg_test","type":"message","role":"assistant","content":[],"model":"test","stop_reason":null,"usage":{"input_tokens":1,"output_tokens":1}}}` + " \n\n " )
flusher. Flush ()
for {
select {
case <- r. Context (). Done (): // クライアント切断で確実に抜ける
return
case <- time. After ( 20 * time.Millisecond):
fmt. Fprint (w, "event: content_block_delta \n " +
`data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"a"}}` + " \n\n " )
flusher. Flush ()
}
}
}))
}
func TestGoodStreamingStopsOnCancel ( t * testing . T ) {
srv := endlessStreamServer (t)
defer srv. Close ()
client := anthropic. NewClient (
option. WithAPIKey ( "test-key" ),
option. WithBaseURL (srv.URL),
)
ctx, cancel := context. WithCancel (context. Background ())
// 容量1: 1トークンだけ読んで放置する=送信側がブロックする状況を作ります
output := make ( chan string , 1 )
done := make ( chan error , 1 )
go func () { done <- goodStreaming (ctx, client, "hello" , output) }()
<- output // 最初のトークンだけ受け取る
cancel () // 読者が離脱した想定
select {
case err := <- done:
if ! errors. Is (err, context.Canceled) {
t. Fatalf ( "context.Canceled を期待しましたが %v でした" , err)
}
case <- time. After ( 2 * time.Second):
t. Fatal ( "キャンセルから2秒経ってもストリーム処理が終了していません" )
}
}
肝は output を容量 1 の channel にしているところです。容量を大きく取ると送信側がブロックせず、ctx.Done() の監視が抜けていても偶然テストが通ってしまいます。実運用で怖いのは、まさに「受け手がいなくなった channel に書き込もうとして固まる」場面ですから、そこを狙って再現しています。
go.uber.org/goleak の VerifyTestMain は、テスト全体の終了時にまだ生きている goroutine を一覧で報告してくれます。goodStreaming が戻ったかどうかは上のタイムアウトで、その内側で SDK が起動した goroutine まで畳まれたかどうかは goleak で、と役割を分けている形です。
実行は go test -race ./claude/... を基本にしています。-race なしだと、キャンセル直後の後片付けで起きる競合を取りこぼすことがあります。
このテストが赤くなったときの原因は、経験上ほとんど一つで、channel への送信を囲む select から case <-ctx.Done(): が消えています。goleak の出力に見慣れない SDK 内部の goroutine が並ぶ場合は、IgnoreTopFunction で黙らせる前に defer srv.Close() の抜けを疑うほうが早いです。
429 と 529 を、じっと待つのではなく散らして待つ
本番では 429(レート制限)と 529(過負荷)が必ず来ます。これをそのままユーザーに見せないための再試行は必要ですが、素直な指数バックオフ(1 秒、2 秒、4 秒)には、この記事の主題と正面から衝突する弱点があります。
先ほどの errgroup による並行 Tool Use で 5 本のリクエストを同時に投げ、5 本とも 429 を受けたと考えてみてください。全員がきっかり 1 秒後に再送します。せっかく並行化した処理が、そのまま同期した再送の波に変わってしまいます。
そこで待ち時間に乱数を混ぜ、サーバが Retry-After を返しているならそちらを優先します。
// retry/retry.go
package retry
import (
" context "
" errors "
" fmt "
" math/rand "
" net/http "
" strconv "
" time "
anthropic " github.com/anthropics/anthropic-sdk-go "
)
const (
baseDelay = 500 * time.Millisecond
maxDelay = 20 * time.Second
)
// retryAfter は「再試行してよいか」と「サーバ指定の待ち時間」を返します
func retryAfter ( err error ) ( bool , time . Duration ) {
var apiErr * anthropic . Error
if ! errors. As (err, & apiErr) {
return false , 0 // ネットワーク層以外の失敗は握りつぶさない
}
switch apiErr.StatusCode {
case http.StatusTooManyRequests, // 429: レート制限
http.StatusServiceUnavailable, // 503: 一時的な停止
529 : // Claude 固有の過負荷コード
default :
return false , 0
}
// Retry-After があれば、こちらの計算より必ず優先します
if resp := apiErr.Response; resp != nil {
if v := resp.Header. Get ( "Retry-After" ); v != "" {
if sec, convErr := strconv. Atoi (v); convErr == nil {
return true , time. Duration (sec) * time.Second
}
}
}
return true , 0
}
// backoff は full jitter(0〜上限の一様乱数)で待ち時間を決めます
func backoff ( attempt int ) time . Duration {
d := baseDelay << uint (attempt)
if d > maxDelay {
d = maxDelay
}
return time. Duration (rand. Int63n ( int64 (d)))
}
// Do は再試行付きでAPI呼び出しを包みます(Go 1.18以降のジェネリクス)
func Do [ T any ]( ctx context . Context , maxAttempts int , fn func () ( T , error )) ( T , error ) {
var zero T
var lastErr error
for attempt := 0 ; attempt < maxAttempts; attempt ++ {
v, err := fn ()
if err == nil {
return v, nil
}
lastErr = err
ok, serverWait := retryAfter (err)
if ! ok {
return zero, err // 再試行しても直らない失敗は即座に返す
}
if attempt == maxAttempts - 1 {
break
}
wait := serverWait
if wait == 0 {
wait = backoff (attempt)
}
select {
case <- time. After (wait):
case <- ctx. Done ():
return zero, fmt. Errorf ( "再試行を中断しました: %w " , ctx. Err ())
}
}
return zero, fmt. Errorf ( " %d 回試行して失敗しました: %w " , maxAttempts, lastErr)
}
呼び出し側はこれだけです。
msg, err := retry. Do (ctx, 4 , func () ( * anthropic . Message , error ) {
return client.Messages. New (ctx, params)
})
backoff が 0〜上限 の一様乱数(full jitter)を返している点が要です。「上限の ±20%」のようにばらつかせる方式もありますが、同時に弾かれた goroutine が多いほど、分布を広く取ったほうが再送のピークが低くなります。並行数が読めない個人開発のサービスでは、こちらのほうが安心して眠れます。
もう一つ、math/rand のグローバル関数は Go 1.20 以降は自動的にシードされるため、rand.Seed を呼ぶ必要はありません。古い記事のコードをそのまま持ってくると、この一行だけが警告として残ります。
再試行を入れると安心しがちですが、これは最後の防波堤です。前の章のレート制限(NewClaudeLimiter)で先に絞るほうが本筋で、Do はそこを抜けてしまった分を受け止める役です。加えて、fn の中で外部に副作用を起こす処理(Tool の実行や DB 更新)を書かないでください。再試行はその副作用ごと繰り返します。私はこれで一度、同じメールを二通送っています。
次の一歩
まず手元の Go プロジェクトに Anthropic SDK を追加して、ConversationSession の実装から試してみてください。その次に入れるものを一つだけ選ぶなら、先ほどの goroutine リーク検出テストをおすすめします。リークは動いているうちは見えず、負荷が上がった日にだけ牙をむくためです。
Streaming と Tool Use を組み合わせたエージェントが動くようになれば、Claude API の Go 統合の核心的な部分はほぼカバーできます。その先は OpenTelemetry による AI オブザーバビリティの実装 を加えることで、本番監視の基盤が完成します。
Go らしいシンプルで堅牢な設計で Claude API を扱える環境が整えば、マイクロサービス全体に AI 機能を広げていくのがずっとスムーズになるはずです。channel と context を軸にした Go の並行処理モデルは、実は LLM の非同期ストリーミングと非常に相性が良いと感じています。