返回 DeepSeek-Reasonix
failure_diagnostic.go
根目录 / internal / provider / failure_diagnostic.go
1 package provider
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "strings"
9 )
10
11 // RequestIdentity keeps the stable connection key separate from the
12 // user-editable label and the selected wire protocol.
13 type RequestIdentity struct {
14 Provider string
15 DisplayName string
16 Protocol string
17 }
18
19 // RequestFailure preserves connection identity for failures that happen before
20 // an HTTP status exists, while retaining the original error for classification.
21 type RequestFailure struct {
22 Identity RequestIdentity
23 Operation string
24 Err error
25 }
26
27 func (e *RequestFailure) Error() string {
28 return fmt.Sprintf("%s: %s: %v", ProviderDisplayLabel(e.Identity.Provider, e.Identity.DisplayName, e.Identity.Protocol), e.Operation, e.Err)
29 }
30
31 func (e *RequestFailure) Unwrap() error { return e.Err }
32
33 func ProtocolDisplayName(kind string) string {
34 switch strings.ToLower(strings.TrimSpace(kind)) {
35 case "openai":
36 return "Chat Completions"
37 case "anthropic":
38 return "Anthropic Messages"
39 case "responses":
40 return "Responses"
41 case "dashscope-responses":
42 return "DashScope Responses"
43 default:
44 return strings.TrimSpace(kind)
45 }
46 }
47
48 func ProviderDisplayLabel(providerID, displayName, protocol string) string {
49 name := strings.TrimSpace(displayName)
50 if name == "" {
51 name = strings.TrimSpace(providerID)
52 }
53 protocolName := ProtocolDisplayName(protocol)
54 if name == "" {
55 return protocolName
56 }
57 if protocolName == "" {
58 return name
59 }
60 return name + " · " + protocolName
61 }
62
63 // FailureKindCancelled classifies a context cancellation: the caller stopped
64 // the request, so no provider failure exists to report.
65 const FailureKindCancelled = "cancelled"
66
67 const FailureKindTransportProtocol = "transport_protocol"
68
69 // FailureDiagnostic contains safe classification only, never response bodies.
70 type FailureDiagnostic struct {
71 Kind string `json:"kind"`
72 Status int `json:"status,omitempty"`
73 TraceID string `json:"traceId,omitempty"`
74 ProviderID string `json:"providerId,omitempty"`
75 ProviderDisplayName string `json:"providerDisplayName,omitempty"`
76 Protocol string `json:"protocol,omitempty"`
77 RequestPath string `json:"requestPath,omitempty"`
78 TransportCode string `json:"transportCode,omitempty"`
79 }
80
81 // FailureDiagnosticDetail renders the safe operator fields shared by live and
82 // persisted failure notices. It intentionally excludes display identity and
83 // any request query or credentials.
84 func FailureDiagnosticDetail(d *FailureDiagnostic) string {
85 if d == nil {
86 return ""
87 }
88 detail := ""
89 if d.ProviderID != "" {
90 detail = "Connection ID: " + d.ProviderID
91 }
92 if d.RequestPath != "" {
93 if detail != "" {
94 detail += "\n"
95 }
96 detail += "Request path: " + d.RequestPath
97 }
98 return detail
99 }
100
101 func DiagnoseFailure(err error) *FailureDiagnostic {
102 if err == nil {
103 return nil
104 }
105 d := &FailureDiagnostic{Kind: "unknown"}
106 var request *RequestFailure
107 if errors.As(err, &request) {
108 d.ProviderID = request.Identity.Provider
109 d.ProviderDisplayName = request.Identity.DisplayName
110 d.Protocol = request.Identity.Protocol
111 }
112 var quota *QuotaError
113 if errors.As(err, &quota) {
114 d.ProviderID = quota.Provider
115 d.ProviderDisplayName = quota.ProviderDisplayName
116 d.Protocol = quota.Protocol
117 }
118 var api *APIError
119 if errors.As(err, &api) {
120 d.Status = api.Status
121 d.ProviderID = api.Provider
122 d.ProviderDisplayName = api.ProviderDisplayName
123 d.Protocol = api.Protocol
124 if len(api.RequestPath) <= 512 && strings.HasPrefix(api.RequestPath, "/") {
125 d.RequestPath = api.RequestPath
126 }
127 // Trace identifiers are opaque tokens, not arbitrary header text.
128 if len(api.TraceID) <= 128 && strings.IndexFunc(api.TraceID, func(r rune) bool {
129 return !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || strings.ContainsRune("-_.:", r))
130 }) < 0 {
131 d.TraceID = api.TraceID
132 }
133 }
134 switch {
135 case errors.Is(err, context.Canceled):
136 d.Kind = FailureKindCancelled
137 case AsRecoveryWaitExhausted(err) != nil:
138 d.Kind = "recovery_wait_exhausted"
139 case AsQuotaError(err) != nil:
140 d.Kind = "quota"
141 d.Status = AsQuotaError(err).Status
142 case errors.As(err, new(*AuthError)):
143 d.Kind = "auth"
144 var auth *AuthError
145 if errors.As(err, &auth) {
146 d.Status = auth.Status
147 d.ProviderID = auth.Provider
148 d.ProviderDisplayName = auth.ProviderDisplayName
149 d.Protocol = auth.Protocol
150 }
151 case AsContextLimitError(err) != nil || AsOutputLimitError(err) != nil:
152 d.Kind = "limit"
153 case AsReasoningReplayError(err) != nil:
154 d.Kind = "protocol"
155 case IsOpaqueBadRequest(err):
156 d.Kind = "upstream_reason_missing"
157 case HTTP2TransportCode(err) != "":
158 d.Kind = FailureKindTransportProtocol
159 d.TransportCode = HTTP2TransportCode(err)
160 case ClassifyRecovery(err).Retryable:
161 d.Kind = "temporary"
162 case api != nil && api.Status >= 400 && api.Status < 500:
163 d.Kind = "request"
164 }
165 return d
166 }
167
168 // IsOpaqueBadRequest deliberately recognizes only empty/model-only 400 bodies.
169 // An unknown structured error may carry a useful reason and must not be guessed.
170 func IsOpaqueBadRequest(err error) bool {
171 var api *APIError
172 if !errors.As(err, &api) || api.Status != 400 {
173 return false
174 }
175 if strings.TrimSpace(api.Body) == "" {
176 return true
177 }
178 var object map[string]json.RawMessage
179 if json.Unmarshal([]byte(api.Body), &object) != nil || object == nil {
180 return false
181 }
182 for key := range object {
183 if key != "model" {
184 return false
185 }
186 }
187 return true
188 }
189
189 lines GO