@@ -24,7 +24,10 @@ import (
2424// forwarded to bedrock-runtime, so clients (e.g. Claude Code in Bedrock gateway
2525// mode with CLAUDE_CODE_SKIP_BEDROCK_AUTH=1) never need AWS credentials.
2626type BedrockConfig struct {
27- Region string
27+ // Regions is the ordered set of Bedrock runtime regions to try. List only
28+ // regions where the target inference profile has model access and TPM quota;
29+ // Bedrock 4xx model-access failures are terminal and are not retried.
30+ Regions []string
2831 Credentials aws.CredentialsProvider
2932 Sources []BedrockCredentialSource
3033 // GatewayToken, when non-empty, must be presented by clients via the
@@ -37,8 +40,8 @@ type BedrockConfig struct {
3740 CostLogPath string
3841 // Bumper, when set, requests a Service Quotas increase when Bedrock throttles
3942 // (HTTP 429), deduped per quota with a cooldown.
40- Bumper * bedrockQuotaBumper
41- nextSource atomic.Uint64
43+ Bumper * bedrockQuotaBumper
44+ nextAttempt atomic.Uint64
4245}
4346
4447type BedrockCredentialSource struct {
@@ -49,6 +52,11 @@ type BedrockCredentialSource struct {
4952
5053const bedrockService = "bedrock"
5154
55+ type bedrockAttempt struct {
56+ Region string
57+ Source BedrockCredentialSource
58+ }
59+
5260func (s Server ) bedrockHandler () http.Handler {
5361 return http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
5462 cfg := s .Bedrock
@@ -79,7 +87,7 @@ func (s Server) bedrockHandler() http.Handler {
7987 headers := http.Header {}
8088 copyBedrockRequestHeaders (headers , r .Header )
8189 started := time .Now ()
82- resp , sourceName , err := s .signAndForwardBedrockWithHeaders (r .Context (), r .Method , upstreamPath , r .URL .RawQuery , headers , body )
90+ resp , sourceName , region , err := s .signAndForwardBedrockWithHeaders (r .Context (), r .Method , upstreamPath , r .URL .RawQuery , headers , body )
8391 if err != nil {
8492 if s .Logger != nil {
8593 s .Logger .Error ("bedrock upstream request failed" , "path" , upstreamPath , "remote_addr" , clientRemoteIP (r ), "user_agent" , r .UserAgent (), "error" , err )
@@ -102,15 +110,16 @@ func (s Server) bedrockHandler() http.Handler {
102110 model := bedrockModelFromPath (upstreamPath )
103111 if resp .StatusCode == http .StatusTooManyRequests {
104112 if s .Logger != nil {
105- s .Logger .Warn ("bedrock throttled" , "model" , model , "path" , upstreamPath , "remote_addr" , clientRemoteIP (r ), "user_agent" , r .UserAgent (), "bedrock_source" , sourceName )
113+ s .Logger .Warn ("bedrock throttled" , "model" , model , "path" , upstreamPath , "remote_addr" , clientRemoteIP (r ), "user_agent" , r .UserAgent (), "bedrock_source" , sourceName , "region" , region )
106114 }
107- cfg .onThrottle (sourceName , model )
115+ cfg .onThrottle (sourceName , region , model )
108116 }
109117 usage , haveUsage := s .streamBedrockResponse (w , resp )
110118 if cfg .CostLogPath != "" && model != "" {
111119 record := bedrockCostRecord {
112120 Timestamp : started .UTC ().Format (time .RFC3339 ),
113121 Model : model ,
122+ Region : region ,
114123 Status : resp .StatusCode ,
115124 DurationMs : time .Since (started ).Milliseconds (),
116125 }
@@ -159,15 +168,15 @@ func (s Server) claudeFableBedrockResponse(ctx context.Context, body []byte) (*h
159168 }
160169 path := "/model/" + bedrockFableModelID + "/" + endpoint
161170 started := time .Now ()
162- resp , sourceName , err := s .signAndForwardBedrock (ctx , http .MethodPost , path , newBody )
171+ resp , sourceName , region , err := s .signAndForwardBedrock (ctx , http .MethodPost , path , newBody )
163172 if err != nil {
164173 return nil , err
165174 }
166175 if resp .StatusCode == http .StatusTooManyRequests {
167176 if s .Logger != nil {
168- s .Logger .Warn ("bedrock throttled" , "model" , bedrockFableModelID , "path" , path , "bedrock_source" , sourceName )
177+ s .Logger .Warn ("bedrock throttled" , "model" , bedrockFableModelID , "path" , path , "bedrock_source" , sourceName , "region" , region )
169178 }
170- cfg .onThrottle (sourceName , bedrockFableModelID )
179+ cfg .onThrottle (sourceName , region , bedrockFableModelID )
171180 }
172181
173182 if stream && resp .StatusCode == http .StatusOK {
@@ -176,7 +185,7 @@ func (s Server) claudeFableBedrockResponse(ctx context.Context, body []byte) (*h
176185 usage , haveUsage := transcodeBedrockToSSE (pw , resp .Body )
177186 _ = resp .Body .Close ()
178187 _ = pw .Close ()
179- s .recordClaudeFableBedrockCost (started , http .StatusOK , usage , haveUsage )
188+ s .recordClaudeFableBedrockCost (started , region , http .StatusOK , usage , haveUsage )
180189 }()
181190 return & http.Response {
182191 Status : "200 OK" ,
@@ -200,10 +209,10 @@ func (s Server) claudeFableBedrockResponse(ctx context.Context, body []byte) (*h
200209 if len (preview ) > 512 {
201210 preview = preview [:512 ]
202211 }
203- s .Logger .Warn ("claude-fable bedrock error response" , "status" , resp .StatusCode , "bedrock_source" , sourceName , "body" , string (preview ))
212+ s .Logger .Warn ("claude-fable bedrock error response" , "status" , resp .StatusCode , "bedrock_source" , sourceName , "region" , region , " body" , string (preview ))
204213 }
205214 usage , haveUsage := parseBedrockInvokeUsage (respBody )
206- s .recordClaudeFableBedrockCost (started , resp .StatusCode , usage , haveUsage )
215+ s .recordClaudeFableBedrockCost (started , region , resp .StatusCode , usage , haveUsage )
207216 return & http.Response {
208217 Status : resp .Status ,
209218 StatusCode : resp .StatusCode ,
@@ -216,14 +225,15 @@ func (s Server) claudeFableBedrockResponse(ctx context.Context, body []byte) (*h
216225 }, nil
217226}
218227
219- func (s Server ) recordClaudeFableBedrockCost (started time.Time , status int , usage bedrockUsage , haveUsage bool ) {
228+ func (s Server ) recordClaudeFableBedrockCost (started time.Time , region string , status int , usage bedrockUsage , haveUsage bool ) {
220229 cfg := s .Bedrock
221230 if cfg == nil || cfg .CostLogPath == "" {
222231 return
223232 }
224233 record := bedrockCostRecord {
225234 Timestamp : started .UTC ().Format (time .RFC3339 ),
226235 Model : bedrockFableModelID ,
236+ Region : region ,
227237 Status : status ,
228238 DurationMs : time .Since (started ).Milliseconds (),
229239 }
@@ -236,51 +246,51 @@ func (s Server) recordClaudeFableBedrockCost(started time.Time, status int, usag
236246
237247// signAndForwardBedrock SigV4-signs a JSON body to bedrock-runtime and returns
238248// the raw response plus the Bedrock source name that handled it.
239- func (s Server ) signAndForwardBedrock (ctx context.Context , method , upstreamPath string , body []byte ) (* http.Response , string , error ) {
249+ func (s Server ) signAndForwardBedrock (ctx context.Context , method , upstreamPath string , body []byte ) (* http.Response , string , string , error ) {
240250 headers := http.Header {"Content-Type" : []string {"application/json" }}
241251 return s .signAndForwardBedrockWithHeaders (ctx , method , upstreamPath , "" , headers , body )
242252}
243253
244- func (s Server ) signAndForwardBedrockWithHeaders (ctx context.Context , method , upstreamPath , rawQuery string , headers http.Header , body []byte ) (* http.Response , string , error ) {
254+ func (s Server ) signAndForwardBedrockWithHeaders (ctx context.Context , method , upstreamPath , rawQuery string , headers http.Header , body []byte ) (* http.Response , string , string , error ) {
245255 cfg := s .Bedrock
246- sources := cfg .orderedSources ()
256+ attempts := cfg .orderedAttempts ()
247257 var firstErr error
248- for i , source := range sources {
249- resp , err := s .signAndForwardBedrockWithSource (ctx , source , method , upstreamPath , rawQuery , headers , body )
258+ for i , attempt := range attempts {
259+ resp , err := s .signAndForwardBedrockWithSource (ctx , attempt . Source , attempt . Region , method , upstreamPath , rawQuery , headers , body )
250260 if err != nil {
251261 if firstErr == nil {
252262 firstErr = err
253263 }
254264 if s .Logger != nil {
255- s .Logger .Warn ("bedrock source failed" , "bedrock_source" , source . Name , "path" , upstreamPath , "error" , err )
265+ s .Logger .Warn ("bedrock source failed" , "bedrock_source" , attempt . Source . Name , "region" , attempt . Region , "path" , upstreamPath , "error" , err )
256266 }
257267 continue
258268 }
259269 // Rotate to the next credential source on a throttle (429, per-account
260270 // TPM) or a Bedrock-side 5xx (e.g. 503 "Bedrock is unable to process
261271 // your request"): both are specific to this source's account/endpoint
262272 // and another source may serve the request fine.
263- if (resp .StatusCode == http .StatusTooManyRequests || resp .StatusCode >= 500 ) && i + 1 < len (sources ) {
273+ if (resp .StatusCode == http .StatusTooManyRequests || resp .StatusCode >= 500 ) && i + 1 < len (attempts ) {
264274 if s .Logger != nil {
265- s .Logger .Warn ("bedrock source unusable, retrying next source" , "bedrock_source" , source . Name , "path" , upstreamPath , "status" , resp .StatusCode )
275+ s .Logger .Warn ("bedrock source unusable, retrying next source" , "bedrock_source" , attempt . Source . Name , "region" , attempt . Region , "path" , upstreamPath , "status" , resp .StatusCode )
266276 }
267277 if resp .StatusCode == http .StatusTooManyRequests {
268- cfg .onThrottle (source . Name , bedrockModelFromPath (upstreamPath ))
278+ cfg .onThrottle (attempt . Source . Name , attempt . Region , bedrockModelFromPath (upstreamPath ))
269279 }
270280 resp .Body .Close ()
271281 continue
272282 }
273- return resp , source . Name , nil
283+ return resp , attempt . Source . Name , attempt . Region , nil
274284 }
275285 if firstErr != nil {
276- return nil , "" , firstErr
286+ return nil , "" , "" , firstErr
277287 }
278- return nil , "" , io .ErrUnexpectedEOF
288+ return nil , "" , "" , io .ErrUnexpectedEOF
279289}
280290
281- func (s Server ) signAndForwardBedrockWithSource (ctx context.Context , source BedrockCredentialSource , method , upstreamPath , rawQuery string , headers http.Header , body []byte ) (* http.Response , error ) {
291+ func (s Server ) signAndForwardBedrockWithSource (ctx context.Context , source BedrockCredentialSource , region , method , upstreamPath , rawQuery string , headers http.Header , body []byte ) (* http.Response , error ) {
282292 cfg := s .Bedrock
283- host := "bedrock-runtime." + cfg . Region + ".amazonaws.com"
293+ host := "bedrock-runtime." + region + ".amazonaws.com"
284294 target := & url.URL {Scheme : "https" , Host : host , Path : upstreamPath , RawQuery : rawQuery }
285295 outReq , err := http .NewRequestWithContext (ctx , method , target .String (), bytes .NewReader (body ))
286296 if err != nil {
@@ -300,7 +310,7 @@ func (s Server) signAndForwardBedrockWithSource(ctx context.Context, source Bedr
300310 if err != nil {
301311 return nil , err
302312 }
303- if err := v4 .NewSigner ().SignHTTP (ctx , creds , outReq , sha256Hex (body ), bedrockService , cfg . Region , time .Now ()); err != nil {
313+ if err := v4 .NewSigner ().SignHTTP (ctx , creds , outReq , sha256Hex (body ), bedrockService , region , time .Now ()); err != nil {
304314 return nil , err
305315 }
306316 transport := cfg .Transport
@@ -314,7 +324,26 @@ func (s Server) signAndForwardBedrockWithSource(ctx context.Context, source Bedr
314324}
315325
316326func (cfg * BedrockConfig ) configured () bool {
317- return strings .TrimSpace (cfg .Region ) != "" && len (cfg .sources ()) > 0
327+ return len (cfg .regions ()) > 0 && len (cfg .sources ()) > 0
328+ }
329+
330+ func (cfg * BedrockConfig ) primaryRegion () string {
331+ if len (cfg .Regions ) == 0 {
332+ return ""
333+ }
334+ return cfg .Regions [0 ]
335+ }
336+
337+ func (cfg * BedrockConfig ) regions () []string {
338+ var out []string
339+ for _ , region := range cfg .Regions {
340+ region = strings .TrimSpace (region )
341+ if region == "" {
342+ continue
343+ }
344+ out = append (out , region )
345+ }
346+ return out
318347}
319348
320349func (cfg * BedrockConfig ) sources () []BedrockCredentialSource {
@@ -335,30 +364,40 @@ func (cfg *BedrockConfig) sources() []BedrockCredentialSource {
335364 return out
336365}
337366
338- func (cfg * BedrockConfig ) orderedSources () []BedrockCredentialSource {
367+ func (cfg * BedrockConfig ) orderedAttempts () []bedrockAttempt {
368+ regions := cfg .regions ()
339369 sources := cfg .sources ()
340- if len (sources ) <= 1 {
341- return sources
370+ if len (regions ) == 0 || len (sources ) == 0 {
371+ return nil
372+ }
373+ attempts := make ([]bedrockAttempt , 0 , len (regions )* len (sources ))
374+ for _ , region := range regions {
375+ for _ , source := range sources {
376+ attempts = append (attempts , bedrockAttempt {Region : region , Source : source })
377+ }
378+ }
379+ if len (attempts ) <= 1 {
380+ return attempts
342381 }
343- start := int (cfg .nextSource .Add (1 )- 1 ) % len (sources )
344- ordered := make ([]BedrockCredentialSource , 0 , len (sources ))
345- ordered = append (ordered , sources [start :]... )
346- ordered = append (ordered , sources [:start ]... )
382+ start := int (cfg .nextAttempt .Add (1 )- 1 ) % len (attempts )
383+ ordered := make ([]bedrockAttempt , 0 , len (attempts ))
384+ ordered = append (ordered , attempts [start :]... )
385+ ordered = append (ordered , attempts [:start ]... )
347386 return ordered
348387}
349388
350- func (cfg * BedrockConfig ) onThrottle (sourceName , model string ) {
389+ func (cfg * BedrockConfig ) onThrottle (sourceName , region , model string ) {
351390 if model == "" {
352391 return
353392 }
354393 for _ , source := range cfg .Sources {
355394 if source .Name == sourceName && source .Bumper != nil {
356- go source .Bumper .onThrottle (model )
395+ go source .Bumper .onThrottle (region , model )
357396 return
358397 }
359398 }
360399 if cfg .Bumper != nil {
361- go cfg .Bumper .onThrottle (model )
400+ go cfg .Bumper .onThrottle (region , model )
362401 }
363402}
364403
0 commit comments