Add projects
This commit is contained in:
parent
2d3a9ad623
commit
8b607dd700
1802 changed files with 503346 additions and 2 deletions
249
backend/internal/runtime/executor/helps/claude_ratelimit.go
Normal file
249
backend/internal/runtime/executor/helps/claude_ratelimit.go
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
package helps
|
||||
|
||||
import (
|
||||
cryptorand "crypto/rand"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultClaudeRateLimitFuzzMinSeconds = 1
|
||||
defaultClaudeRateLimitFuzzMaxSeconds = 30
|
||||
)
|
||||
|
||||
// ClaudeHeadersIndicateUnifiedRateLimitRejection reports whether response headers explicitly
|
||||
// declare an Anthropic shared 5h or 7d rate-limit rejection. A Fable-only 7d_oi rejection
|
||||
// remains model-scoped when both shared windows are explicitly allowed.
|
||||
func ClaudeHeadersIndicateUnifiedRateLimitRejection(headers http.Header) bool {
|
||||
if headers == nil {
|
||||
return false
|
||||
}
|
||||
unifiedStatus := strings.ToLower(strings.TrimSpace(getHeaderCaseInsensitive(headers, "Anthropic-Ratelimit-Unified-Status")))
|
||||
status5h := strings.ToLower(strings.TrimSpace(getHeaderCaseInsensitive(headers, "Anthropic-Ratelimit-Unified-5h-Status")))
|
||||
if status5h == "rejected" {
|
||||
return true
|
||||
}
|
||||
status7d := strings.ToLower(strings.TrimSpace(getHeaderCaseInsensitive(headers, "Anthropic-Ratelimit-Unified-7d-Status")))
|
||||
if status7d == "rejected" {
|
||||
return true
|
||||
}
|
||||
if unifiedStatus != "rejected" {
|
||||
return false
|
||||
}
|
||||
status7dOI := strings.ToLower(strings.TrimSpace(getHeaderCaseInsensitive(headers, "Anthropic-Ratelimit-Unified-7d_oi-Status")))
|
||||
return !isFableOnlyRejection(status5h, status7d, status7dOI)
|
||||
}
|
||||
|
||||
func isFableOnlyRejection(status5h, status7d, status7dOI string) bool {
|
||||
return status5h == "allowed" && status7d == "allowed" && status7dOI == "rejected"
|
||||
}
|
||||
|
||||
// ParseClaudeRateLimitReset inspects Anthropic response headers for shared and Fable-specific
|
||||
// unified rate-limit and standard Retry-After reset information, returning the conservative cooldown
|
||||
// duration including a bounded non-negative random grace period.
|
||||
// If no valid future reset information is present, it returns nil.
|
||||
func ParseClaudeRateLimitReset(headers http.Header, now time.Time) *time.Duration {
|
||||
return parseClaudeRateLimitResetWithFuzz(headers, now, defaultClaudeRateLimitFuzzMinSeconds, defaultClaudeRateLimitFuzzMaxSeconds)
|
||||
}
|
||||
|
||||
func parseClaudeRateLimitResetWithFuzz(headers http.Header, now time.Time, minFuzzSec, maxFuzzSec int) *time.Duration {
|
||||
if headers == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
unifiedStatus := strings.ToLower(strings.TrimSpace(getHeaderCaseInsensitive(headers, "Anthropic-Ratelimit-Unified-Status")))
|
||||
status5h := strings.ToLower(strings.TrimSpace(getHeaderCaseInsensitive(headers, "Anthropic-Ratelimit-Unified-5h-Status")))
|
||||
status7d := strings.ToLower(strings.TrimSpace(getHeaderCaseInsensitive(headers, "Anthropic-Ratelimit-Unified-7d-Status")))
|
||||
status7dOI := strings.ToLower(strings.TrimSpace(getHeaderCaseInsensitive(headers, "Anthropic-Ratelimit-Unified-7d_oi-Status")))
|
||||
fableOnlyRejection := isFableOnlyRejection(status5h, status7d, status7dOI)
|
||||
|
||||
var candidateDeadlines []time.Time
|
||||
var rejectedWindows []string
|
||||
|
||||
if unifiedStatus == "rejected" {
|
||||
rejectedWindows = append(rejectedWindows, "unified")
|
||||
}
|
||||
if status5h == "rejected" {
|
||||
rejectedWindows = append(rejectedWindows, "5h")
|
||||
}
|
||||
if status7d == "rejected" {
|
||||
rejectedWindows = append(rejectedWindows, "7d")
|
||||
}
|
||||
if status7dOI == "rejected" {
|
||||
rejectedWindows = append(rejectedWindows, "7d_oi")
|
||||
}
|
||||
|
||||
// 1. Retry-After header
|
||||
if rawRetryAfter := getHeaderCaseInsensitive(headers, "Retry-After"); rawRetryAfter != "" {
|
||||
if !containsString(rejectedWindows, "retry-after") {
|
||||
rejectedWindows = append(rejectedWindows, "retry-after")
|
||||
}
|
||||
if t, ok := parseRetryAfterHeader(rawRetryAfter, now); ok && t.After(now) {
|
||||
candidateDeadlines = append(candidateDeadlines, t)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 5-hour window reset (only when rejected)
|
||||
if status5h == "rejected" {
|
||||
if raw := getHeaderCaseInsensitive(headers, "Anthropic-Ratelimit-Unified-5h-Reset"); raw != "" {
|
||||
if t, ok := parseUnixOrTimestamp(raw); ok && t.After(now) {
|
||||
candidateDeadlines = append(candidateDeadlines, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 7-day window reset (only when rejected)
|
||||
if status7d == "rejected" {
|
||||
if raw := getHeaderCaseInsensitive(headers, "Anthropic-Ratelimit-Unified-7d-Reset"); raw != "" {
|
||||
if t, ok := parseUnixOrTimestamp(raw); ok && t.After(now) {
|
||||
candidateDeadlines = append(candidateDeadlines, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Fable-specific 7-day window reset (only when rejected and not a Fable-only rejection)
|
||||
if status7dOI == "rejected" && !fableOnlyRejection {
|
||||
if raw := getHeaderCaseInsensitive(headers, "Anthropic-Ratelimit-Unified-7d_oi-Reset"); raw != "" {
|
||||
if t, ok := parseUnixOrTimestamp(raw); ok && t.After(now) {
|
||||
candidateDeadlines = append(candidateDeadlines, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Unified reset header:
|
||||
unifiedRejected := !fableOnlyRejection && (unifiedStatus == "rejected" || status5h == "rejected" || status7d == "rejected" || status7dOI == "rejected" ||
|
||||
(unifiedStatus == "" && status5h != "allowed" && status7d != "allowed"))
|
||||
|
||||
if unifiedRejected {
|
||||
if raw := getHeaderCaseInsensitive(headers, "Anthropic-Ratelimit-Unified-Reset"); raw != "" {
|
||||
if !containsString(rejectedWindows, "unified") {
|
||||
rejectedWindows = append(rejectedWindows, "unified")
|
||||
}
|
||||
if t, ok := parseUnixOrTimestamp(raw); ok && t.After(now) {
|
||||
candidateDeadlines = append(candidateDeadlines, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(candidateDeadlines) == 0 {
|
||||
if len(rejectedWindows) > 0 {
|
||||
log.WithFields(log.Fields{
|
||||
"rejected_windows": strings.Join(rejectedWindows, ","),
|
||||
"status": "fallback_exponential_backoff",
|
||||
}).Info("Anthropic rate limit window rejected; falling back to generic exponential backoff")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Pick the latest applicable deadline across rejected windows
|
||||
var latestDeadline time.Time
|
||||
for _, deadline := range candidateDeadlines {
|
||||
if deadline.After(latestDeadline) {
|
||||
latestDeadline = deadline
|
||||
}
|
||||
}
|
||||
|
||||
if latestDeadline.IsZero() || !latestDeadline.After(now) {
|
||||
if len(rejectedWindows) > 0 {
|
||||
log.WithFields(log.Fields{
|
||||
"rejected_windows": strings.Join(rejectedWindows, ","),
|
||||
"status": "fallback_exponential_backoff",
|
||||
}).Info("Anthropic rate limit window rejected; falling back to generic exponential backoff")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
baseDuration := latestDeadline.Sub(now)
|
||||
fuzz := randomClaudeFuzzDuration(minFuzzSec, maxFuzzSec)
|
||||
effectiveDuration := baseDuration + fuzz
|
||||
|
||||
log.WithFields(log.Fields{
|
||||
"rejected_windows": strings.Join(rejectedWindows, ","),
|
||||
"effective_cooldown": effectiveDuration.String(),
|
||||
"base_cooldown": baseDuration.String(),
|
||||
"fuzz": fuzz.String(),
|
||||
"deadline": latestDeadline.Format(time.RFC3339),
|
||||
}).Info("parsed Anthropic rate limit reset headers")
|
||||
|
||||
return &effectiveDuration
|
||||
}
|
||||
|
||||
func containsString(list []string, target string) bool {
|
||||
for _, item := range list {
|
||||
if item == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func getHeaderCaseInsensitive(h http.Header, target string) string {
|
||||
if h == nil {
|
||||
return ""
|
||||
}
|
||||
if val := h.Get(target); val != "" {
|
||||
return val
|
||||
}
|
||||
for k, v := range h {
|
||||
if strings.EqualFold(k, target) && len(v) > 0 {
|
||||
return v[0]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseUnixOrTimestamp(raw string) (time.Time, bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
if sec, err := strconv.ParseFloat(raw, 64); err == nil && sec > 0 {
|
||||
secInt := int64(sec)
|
||||
nsec := int64((sec - float64(secInt)) * 1e9)
|
||||
return time.Unix(secInt, nsec), true
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, raw); err == nil {
|
||||
return t, true
|
||||
}
|
||||
if t, err := http.ParseTime(raw); err == nil {
|
||||
return t, true
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func parseRetryAfterHeader(raw string, now time.Time) (time.Time, bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
if sec, err := strconv.ParseFloat(raw, 64); err == nil && sec > 0 {
|
||||
d := time.Duration(sec * float64(time.Second))
|
||||
return now.Add(d), true
|
||||
}
|
||||
if t, err := http.ParseTime(raw); err == nil {
|
||||
return t, true
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, raw); err == nil {
|
||||
return t, true
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func randomClaudeFuzzDuration(minSec, maxSec int) time.Duration {
|
||||
if maxSec <= minSec {
|
||||
if minSec < 0 {
|
||||
return 0
|
||||
}
|
||||
return time.Duration(minSec) * time.Second
|
||||
}
|
||||
nBig, err := cryptorand.Int(cryptorand.Reader, big.NewInt(int64(maxSec-minSec+1)))
|
||||
if err != nil {
|
||||
return time.Duration(minSec) * time.Second
|
||||
}
|
||||
return time.Duration(minSec+int(nBig.Int64())) * time.Second
|
||||
}
|
||||
Loading…
Reference in a new issue