feat: add gateway billing estimate and rate limit details
This commit is contained in:
@@ -597,7 +597,7 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
|
||||
status := statusFromRunError(runErr)
|
||||
errorPayload := map[string]any{
|
||||
"code": runErrorCode(runErr),
|
||||
"message": runErr.Error(),
|
||||
"message": runErrorMessage(runErr),
|
||||
"status": status,
|
||||
}
|
||||
if result.Task.ID != "" {
|
||||
@@ -606,6 +606,9 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
|
||||
if result.Task.RequestID != "" {
|
||||
errorPayload["requestId"] = result.Task.RequestID
|
||||
}
|
||||
for key, value := range runErrorDetails(runErr) {
|
||||
errorPayload[key] = value
|
||||
}
|
||||
sendSSE(w, "error", map[string]any{"error": errorPayload})
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
@@ -626,7 +629,7 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
|
||||
if !requestStillConnected(r) {
|
||||
return
|
||||
}
|
||||
writeError(w, statusFromRunError(runErr), runErr.Error(), runErrorCode(runErr))
|
||||
writeErrorWithDetails(w, statusFromRunError(runErr), runErrorMessage(runErr), runErrorDetails(runErr), runErrorCode(runErr))
|
||||
return
|
||||
}
|
||||
if !requestStillConnected(r) {
|
||||
@@ -742,6 +745,138 @@ func runErrorCode(err error) string {
|
||||
return clients.ErrorCode(err)
|
||||
}
|
||||
|
||||
func runErrorMessage(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
if summary := rateLimitErrorSummary(err); summary != "" {
|
||||
return err.Error() + ";" + summary
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
func runErrorDetails(err error) map[string]any {
|
||||
if detail := rateLimitErrorDetail(err); len(detail) > 0 {
|
||||
return map[string]any{"rateLimit": detail}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rateLimitErrorSummary(err error) string {
|
||||
var limitErr *store.RateLimitExceededError
|
||||
if !errors.As(err, &limitErr) {
|
||||
return ""
|
||||
}
|
||||
scopeLabel := "限流对象"
|
||||
switch limitErr.ScopeType {
|
||||
case "user_group":
|
||||
scopeLabel = "用户组"
|
||||
case "platform_model":
|
||||
scopeLabel = "平台模型"
|
||||
}
|
||||
scopeName := strings.TrimSpace(limitErr.ScopeName)
|
||||
if scopeName == "" {
|
||||
scopeName = strings.TrimSpace(limitErr.ScopeKey)
|
||||
}
|
||||
if groupKey := stringValue(limitErr.ScopeMetadata["groupKey"]); limitErr.ScopeType == "user_group" && groupKey != "" && groupKey != scopeName {
|
||||
scopeName = fmt.Sprintf("%s(%s)", scopeName, groupKey)
|
||||
}
|
||||
projected := limitErr.Projected
|
||||
if projected <= 0 {
|
||||
projected = limitErr.Current + limitErr.Amount
|
||||
}
|
||||
parts := []string{
|
||||
fmt.Sprintf("限流摘要:%s %s 的 %s 超限", scopeLabel, scopeName, limitErr.Metric),
|
||||
fmt.Sprintf("当前 %s,本次 %s,预计 %s,限制 %s", formatRateLimitValue(limitErr.Current), formatRateLimitValue(limitErr.Amount), formatRateLimitValue(projected), formatRateLimitValue(limitErr.Limit)),
|
||||
}
|
||||
if limitErr.WindowSeconds > 0 {
|
||||
parts = append(parts, fmt.Sprintf("窗口 %d 秒", limitErr.WindowSeconds))
|
||||
}
|
||||
if limitErr.RetryAfter > 0 {
|
||||
parts = append(parts, fmt.Sprintf("约%s后可重试", formatRateLimitDuration(limitErr.RetryAfter)))
|
||||
} else if !limitErr.Retryable {
|
||||
parts = append(parts, "该请求超过单次限额,不能排队重试")
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
func rateLimitErrorDetail(err error) map[string]any {
|
||||
var limitErr *store.RateLimitExceededError
|
||||
if !errors.As(err, &limitErr) {
|
||||
return nil
|
||||
}
|
||||
detail := map[string]any{
|
||||
"scopeType": limitErr.ScopeType,
|
||||
"scopeKey": limitErr.ScopeKey,
|
||||
"scopeName": limitErr.ScopeName,
|
||||
"metric": limitErr.Metric,
|
||||
"limit": limitErr.Limit,
|
||||
"amount": limitErr.Amount,
|
||||
"current": limitErr.Current,
|
||||
"used": limitErr.Used,
|
||||
"reserved": limitErr.Reserved,
|
||||
"projected": limitErr.Projected,
|
||||
"windowSeconds": limitErr.WindowSeconds,
|
||||
"retryable": limitErr.Retryable,
|
||||
"exceeded": map[string]any{
|
||||
"metric": limitErr.Metric,
|
||||
"current": limitErr.Current,
|
||||
"amount": limitErr.Amount,
|
||||
"projected": limitErr.Projected,
|
||||
"limit": limitErr.Limit,
|
||||
},
|
||||
}
|
||||
if limitErr.RetryAfter > 0 {
|
||||
detail["retryAfterMs"] = limitErr.RetryAfter.Milliseconds()
|
||||
}
|
||||
if !limitErr.ResetAt.IsZero() {
|
||||
detail["resetAt"] = limitErr.ResetAt.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
if len(limitErr.Policy) > 0 {
|
||||
detail["rateLimitPolicy"] = limitErr.Policy
|
||||
if matchedRule := matchedRateLimitRule(limitErr.Policy, limitErr.Metric); len(matchedRule) > 0 {
|
||||
detail["matchedRule"] = matchedRule
|
||||
}
|
||||
}
|
||||
if len(limitErr.ScopeMetadata) > 0 {
|
||||
detail["scopeMetadata"] = limitErr.ScopeMetadata
|
||||
}
|
||||
if limitErr.ScopeType == "user_group" {
|
||||
userGroup := map[string]any{
|
||||
"id": limitErr.ScopeKey,
|
||||
"name": limitErr.ScopeName,
|
||||
}
|
||||
if groupKey := stringValue(limitErr.ScopeMetadata["groupKey"]); groupKey != "" {
|
||||
userGroup["groupKey"] = groupKey
|
||||
}
|
||||
detail["userGroup"] = userGroup
|
||||
}
|
||||
return detail
|
||||
}
|
||||
|
||||
func formatRateLimitValue(value float64) string {
|
||||
return strconv.FormatFloat(value, 'f', -1, 64)
|
||||
}
|
||||
|
||||
func formatRateLimitDuration(duration time.Duration) string {
|
||||
if duration < time.Second {
|
||||
return strconv.FormatInt(duration.Milliseconds(), 10) + "毫秒"
|
||||
}
|
||||
seconds := duration.Seconds()
|
||||
return strconv.FormatFloat(seconds, 'f', -1, 64) + "秒"
|
||||
}
|
||||
|
||||
func matchedRateLimitRule(policy map[string]any, metric string) map[string]any {
|
||||
rules, _ := policy["rules"].([]any)
|
||||
for _, rawRule := range rules {
|
||||
rule, _ := rawRule.(map[string]any)
|
||||
if stringValue(rule["metric"]) == metric {
|
||||
return rule
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) listTasks(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func TestRateLimitErrorDetailIncludesUserGroupAndExceededMetric(t *testing.T) {
|
||||
resetAt := time.Date(2026, 5, 15, 10, 30, 0, 0, time.UTC)
|
||||
detail := rateLimitErrorDetail(&store.RateLimitExceededError{
|
||||
ScopeType: "user_group",
|
||||
ScopeKey: "group-1",
|
||||
ScopeName: "VIP 用户组",
|
||||
ScopeMetadata: map[string]any{"groupKey": "vip"},
|
||||
Metric: "rpm",
|
||||
Limit: 2,
|
||||
Amount: 1,
|
||||
Current: 2,
|
||||
Used: 1,
|
||||
Reserved: 1,
|
||||
Projected: 3,
|
||||
WindowSeconds: 60,
|
||||
ResetAt: resetAt,
|
||||
RetryAfter: 5 * time.Second,
|
||||
Retryable: true,
|
||||
Policy: map[string]any{
|
||||
"rules": []any{
|
||||
map[string]any{"metric": "rpm", "limit": float64(2), "windowSeconds": float64(60)},
|
||||
},
|
||||
},
|
||||
})
|
||||
if detail["metric"] != "rpm" || detail["projected"] != float64(3) || detail["limit"] != float64(2) {
|
||||
t.Fatalf("unexpected exceeded detail: %+v", detail)
|
||||
}
|
||||
userGroup, _ := detail["userGroup"].(map[string]any)
|
||||
if userGroup["id"] != "group-1" || userGroup["groupKey"] != "vip" || userGroup["name"] != "VIP 用户组" {
|
||||
t.Fatalf("missing user group detail: %+v", detail)
|
||||
}
|
||||
matchedRule, _ := detail["matchedRule"].(map[string]any)
|
||||
if matchedRule["metric"] != "rpm" {
|
||||
t.Fatalf("missing matched rule: %+v", detail)
|
||||
}
|
||||
if detail["retryAfterMs"] != int64(5000) || detail["resetAt"] != resetAt.Format(time.RFC3339Nano) {
|
||||
t.Fatalf("missing retry/reset detail: %+v", detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunErrorMessageIncludesRateLimitSummary(t *testing.T) {
|
||||
message := runErrorMessage(&store.RateLimitExceededError{
|
||||
ScopeType: "user_group",
|
||||
ScopeKey: "group-1",
|
||||
ScopeName: "VIP 用户组",
|
||||
ScopeMetadata: map[string]any{"groupKey": "vip"},
|
||||
Metric: "rpm",
|
||||
Limit: 2,
|
||||
Amount: 1,
|
||||
Current: 2,
|
||||
Projected: 3,
|
||||
WindowSeconds: 60,
|
||||
RetryAfter: 5 * time.Second,
|
||||
Retryable: true,
|
||||
Message: "rate limit exceeded: rpm window has no remaining capacity",
|
||||
})
|
||||
for _, expected := range []string{"限流摘要", "用户组 VIP 用户组(vip)", "rpm 超限", "当前 2", "本次 1", "预计 3", "限制 2", "窗口 60 秒", "约5秒后可重试"} {
|
||||
if !strings.Contains(message, expected) {
|
||||
t.Fatalf("message %q should contain %q", message, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,10 @@ func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, message string, codes ...string) {
|
||||
writeErrorWithDetails(w, status, message, nil, codes...)
|
||||
}
|
||||
|
||||
func writeErrorWithDetails(w http.ResponseWriter, status int, message string, details map[string]any, codes ...string) {
|
||||
errorPayload := map[string]any{
|
||||
"message": message,
|
||||
"status": status,
|
||||
@@ -23,6 +27,9 @@ func writeError(w http.ResponseWriter, status int, message string, codes ...stri
|
||||
errorPayload["code"] = code
|
||||
}
|
||||
}
|
||||
for key, value := range details {
|
||||
errorPayload[key] = value
|
||||
}
|
||||
writeJSON(w, status, map[string]any{"error": errorPayload})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user