249 lines
8.2 KiB
Go
249 lines
8.2 KiB
Go
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
|
|
}
|