Skip to content

Commit 91e7591

Browse files
committed
fix(executor): add transient 429 resource exhausted handling with retry logic
1 parent c8b7e2b commit 91e7591

2 files changed

Lines changed: 154 additions & 7 deletions

File tree

internal/runtime/executor/antigravity_executor.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,28 @@ func classifyAntigravity429(body []byte) antigravity429Category {
261261
return antigravity429Unknown
262262
}
263263

264+
func antigravityHasQuotaResetDelayOrModelInfo(body []byte) bool {
265+
if len(body) == 0 {
266+
return false
267+
}
268+
details := gjson.GetBytes(body, "error.details")
269+
if !details.Exists() || !details.IsArray() {
270+
return false
271+
}
272+
for _, detail := range details.Array() {
273+
if detail.Get("@type").String() != "type.googleapis.com/google.rpc.ErrorInfo" {
274+
continue
275+
}
276+
if strings.TrimSpace(detail.Get("metadata.quotaResetDelay").String()) != "" {
277+
return true
278+
}
279+
if strings.TrimSpace(detail.Get("metadata.model").String()) != "" {
280+
return true
281+
}
282+
}
283+
return false
284+
}
285+
264286
func antigravityCreditsRetryEnabled(cfg *config.Config) bool {
265287
return cfg != nil && cfg.QuotaExceeded.AntigravityCredits
266288
}
@@ -362,6 +384,12 @@ func shouldMarkAntigravityCreditsExhausted(statusCode int, body []byte, reqErr e
362384
lowerBody := strings.ToLower(string(body))
363385
for _, keyword := range antigravityCreditsExhaustedKeywords {
364386
if strings.Contains(lowerBody, keyword) {
387+
if keyword == "resource has been exhausted" &&
388+
statusCode == http.StatusTooManyRequests &&
389+
classifyAntigravity429(body) == antigravity429Unknown &&
390+
!antigravityHasQuotaResetDelayOrModelInfo(body) {
391+
return false
392+
}
365393
return true
366394
}
367395
}
@@ -575,6 +603,14 @@ attemptLoop:
575603
log.Debugf("antigravity executor: rate limited on base url %s, retrying with fallback base url: %s", baseURL, baseURLs[idx+1])
576604
continue
577605
}
606+
if antigravityShouldRetryTransientResourceExhausted429(httpResp.StatusCode, bodyBytes) && attempt+1 < attempts {
607+
delay := antigravityTransient429RetryDelay(attempt)
608+
log.Debugf("antigravity executor: transient 429 resource exhausted for model %s, retrying in %s (attempt %d/%d)", baseModel, delay, attempt+1, attempts)
609+
if errWait := antigravityWait(ctx, delay); errWait != nil {
610+
return resp, errWait
611+
}
612+
continue attemptLoop
613+
}
578614
if antigravityShouldRetryNoCapacity(httpResp.StatusCode, bodyBytes) {
579615
if idx+1 < len(baseURLs) {
580616
log.Debugf("antigravity executor: no capacity on base url %s, retrying with fallback base url: %s", baseURL, baseURLs[idx+1])
@@ -742,6 +778,14 @@ attemptLoop:
742778
log.Debugf("antigravity executor: rate limited on base url %s, retrying with fallback base url: %s", baseURL, baseURLs[idx+1])
743779
continue
744780
}
781+
if antigravityShouldRetryTransientResourceExhausted429(httpResp.StatusCode, bodyBytes) && attempt+1 < attempts {
782+
delay := antigravityTransient429RetryDelay(attempt)
783+
log.Debugf("antigravity executor: transient 429 resource exhausted for model %s, retrying in %s (attempt %d/%d)", baseModel, delay, attempt+1, attempts)
784+
if errWait := antigravityWait(ctx, delay); errWait != nil {
785+
return resp, errWait
786+
}
787+
continue attemptLoop
788+
}
745789
if antigravityShouldRetryNoCapacity(httpResp.StatusCode, bodyBytes) {
746790
if idx+1 < len(baseURLs) {
747791
log.Debugf("antigravity executor: no capacity on base url %s, retrying with fallback base url: %s", baseURL, baseURLs[idx+1])
@@ -1158,6 +1202,14 @@ attemptLoop:
11581202
log.Debugf("antigravity executor: rate limited on base url %s, retrying with fallback base url: %s", baseURL, baseURLs[idx+1])
11591203
continue
11601204
}
1205+
if antigravityShouldRetryTransientResourceExhausted429(httpResp.StatusCode, bodyBytes) && attempt+1 < attempts {
1206+
delay := antigravityTransient429RetryDelay(attempt)
1207+
log.Debugf("antigravity executor: transient 429 resource exhausted for model %s, retrying in %s (attempt %d/%d)", baseModel, delay, attempt+1, attempts)
1208+
if errWait := antigravityWait(ctx, delay); errWait != nil {
1209+
return nil, errWait
1210+
}
1211+
continue attemptLoop
1212+
}
11611213
if antigravityShouldRetryNoCapacity(httpResp.StatusCode, bodyBytes) {
11621214
if idx+1 < len(baseURLs) {
11631215
log.Debugf("antigravity executor: no capacity on base url %s, retrying with fallback base url: %s", baseURL, baseURLs[idx+1])
@@ -1774,6 +1826,24 @@ func antigravityShouldRetryNoCapacity(statusCode int, body []byte) bool {
17741826
return strings.Contains(msg, "no capacity available")
17751827
}
17761828

1829+
func antigravityShouldRetryTransientResourceExhausted429(statusCode int, body []byte) bool {
1830+
if statusCode != http.StatusTooManyRequests {
1831+
return false
1832+
}
1833+
if len(body) == 0 {
1834+
return false
1835+
}
1836+
if classifyAntigravity429(body) != antigravity429Unknown {
1837+
return false
1838+
}
1839+
status := strings.TrimSpace(gjson.GetBytes(body, "error.status").String())
1840+
if !strings.EqualFold(status, "RESOURCE_EXHAUSTED") {
1841+
return false
1842+
}
1843+
msg := strings.ToLower(string(body))
1844+
return strings.Contains(msg, "resource has been exhausted")
1845+
}
1846+
17771847
func antigravityNoCapacityRetryDelay(attempt int) time.Duration {
17781848
if attempt < 0 {
17791849
attempt = 0
@@ -1785,6 +1855,17 @@ func antigravityNoCapacityRetryDelay(attempt int) time.Duration {
17851855
return delay
17861856
}
17871857

1858+
func antigravityTransient429RetryDelay(attempt int) time.Duration {
1859+
if attempt < 0 {
1860+
attempt = 0
1861+
}
1862+
delay := time.Duration(attempt+1) * 100 * time.Millisecond
1863+
if delay > 500*time.Millisecond {
1864+
delay = 500 * time.Millisecond
1865+
}
1866+
return delay
1867+
}
1868+
17881869
func antigravityWait(ctx context.Context, wait time.Duration) error {
17891870
if wait <= 0 {
17901871
return nil

internal/runtime/executor/antigravity_executor_credits_test.go

Lines changed: 73 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -82,20 +82,86 @@ func TestInjectEnabledCreditTypes(t *testing.T) {
8282
}
8383

8484
func TestShouldMarkAntigravityCreditsExhausted(t *testing.T) {
85-
for _, body := range [][]byte{
86-
[]byte(`{"error":{"message":"Insufficient GOOGLE_ONE_AI credits"}}`),
87-
[]byte(`{"error":{"message":"minimumCreditAmountForUsage requirement not met"}}`),
88-
[]byte(`{"error":{"message":"Resource has been exhausted"}}`),
89-
} {
90-
if !shouldMarkAntigravityCreditsExhausted(http.StatusForbidden, body, nil) {
85+
t.Run("credit errors are marked", func(t *testing.T) {
86+
for _, body := range [][]byte{
87+
[]byte(`{"error":{"message":"Insufficient GOOGLE_ONE_AI credits"}}`),
88+
[]byte(`{"error":{"message":"minimumCreditAmountForUsage requirement not met"}}`),
89+
} {
90+
if !shouldMarkAntigravityCreditsExhausted(http.StatusForbidden, body, nil) {
91+
t.Fatalf("shouldMarkAntigravityCreditsExhausted(%s) = false, want true", string(body))
92+
}
93+
}
94+
})
95+
96+
t.Run("transient 429 resource exhausted is not marked", func(t *testing.T) {
97+
body := []byte(`{"error":{"code":429,"message":"Resource has been exhausted (e.g. check quota).","status":"RESOURCE_EXHAUSTED"}}`)
98+
if shouldMarkAntigravityCreditsExhausted(http.StatusTooManyRequests, body, nil) {
99+
t.Fatalf("shouldMarkAntigravityCreditsExhausted(%s) = true, want false", string(body))
100+
}
101+
})
102+
103+
t.Run("resource exhausted with quota metadata is still marked", func(t *testing.T) {
104+
body := []byte(`{"error":{"code":429,"message":"Resource has been exhausted","status":"RESOURCE_EXHAUSTED","details":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo","metadata":{"quotaResetDelay":"1h","model":"claude-sonnet-4-6"}}]}}`)
105+
if !shouldMarkAntigravityCreditsExhausted(http.StatusTooManyRequests, body, nil) {
91106
t.Fatalf("shouldMarkAntigravityCreditsExhausted(%s) = false, want true", string(body))
92107
}
93-
}
108+
})
109+
94110
if shouldMarkAntigravityCreditsExhausted(http.StatusServiceUnavailable, []byte(`{"error":{"message":"credits exhausted"}}`), nil) {
95111
t.Fatal("shouldMarkAntigravityCreditsExhausted() = true for 5xx, want false")
96112
}
97113
}
98114

115+
func TestAntigravityExecute_RetriesTransient429ResourceExhausted(t *testing.T) {
116+
resetAntigravityCreditsRetryState()
117+
t.Cleanup(resetAntigravityCreditsRetryState)
118+
119+
var requestCount int
120+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
121+
requestCount++
122+
switch requestCount {
123+
case 1:
124+
w.WriteHeader(http.StatusTooManyRequests)
125+
_, _ = w.Write([]byte(`{"error":{"code":429,"message":"Resource has been exhausted (e.g. check quota).","status":"RESOURCE_EXHAUSTED"}}`))
126+
case 2:
127+
w.Header().Set("Content-Type", "application/json")
128+
_, _ = w.Write([]byte(`{"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"ok"}]}}],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":1,"totalTokenCount":2}}}`))
129+
default:
130+
t.Fatalf("unexpected request count %d", requestCount)
131+
}
132+
}))
133+
defer server.Close()
134+
135+
exec := NewAntigravityExecutor(&config.Config{RequestRetry: 1})
136+
auth := &cliproxyauth.Auth{
137+
ID: "auth-transient-429",
138+
Attributes: map[string]string{
139+
"base_url": server.URL,
140+
},
141+
Metadata: map[string]any{
142+
"access_token": "token",
143+
"project_id": "project-1",
144+
"expired": time.Now().Add(1 * time.Hour).Format(time.RFC3339),
145+
},
146+
}
147+
148+
resp, err := exec.Execute(context.Background(), auth, cliproxyexecutor.Request{
149+
Model: "gemini-2.5-flash",
150+
Payload: []byte(`{"request":{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}}`),
151+
}, cliproxyexecutor.Options{
152+
SourceFormat: sdktranslator.FormatAntigravity,
153+
})
154+
if err != nil {
155+
t.Fatalf("Execute() error = %v", err)
156+
}
157+
if len(resp.Payload) == 0 {
158+
t.Fatal("Execute() returned empty payload")
159+
}
160+
if requestCount != 2 {
161+
t.Fatalf("request count = %d, want 2", requestCount)
162+
}
163+
}
164+
99165
func TestAntigravityExecute_RetriesQuotaExhaustedWithCredits(t *testing.T) {
100166
resetAntigravityCreditsRetryState()
101167
t.Cleanup(resetAntigravityCreditsRetryState)

0 commit comments

Comments
 (0)