已合并
Report actual sync HTTP attempt evidence #174
urandon创建于 24 天前
Report actual sync HTTP attempt evidence #174
已合并
urandon创建于 24 天前
共 19 个文件变更+1468-45
Acmd/gitcode-mcp/sync_attempt_test.go+141-0文件内容审核中,请稍后刷新重试
Mdocs/test-architecture.md+21-0文件内容审核中,请稍后刷新重试
@@ -141,6 +141,31 @@ adapter_unavailable
141 141 
142**Fix:** Reads work offline from cache. Writes require network and token.142**Fix:** Reads work offline from cache. Writes require network and token.
143 143 
144+### Sync cancellation and HTTP attempt evidence
145+ 
146+CLI error diagnostics and MCP `sync_live` diagnostics distinguish
147+`operation_cancelled` / `operation_timeout` from `live_transport_failure`.
148+`http_attempted` reports entry into the configured HTTP transport, not selection
149+of live mode, request construction, time spent waiting for a rate limit, or
150+successful delivery to GitCode. It is not a packet count or write confirmation.
151+ 
152+A cancelled or expired context before transport entry reports
153+`http_attempted: false`. A timeout/cancellation returned by an entered transport
154+reports `live_transport_failure` with `http_attempted: true`. Cancellation later
155+in a multi-collection sync can report an operation interruption with
156+`http_attempted: true`: even an empty successful list already contacted the
157+provider. Partial results and retries retain that fact independently of metrics.
158+This also applies when a later request is cancelled in its local rate-limit
159+wait: an earlier successful request is not evidence that the failing request
160+entered transport. Confirmed provider/schema failures or an actually entered
161+failed request still retain their failure classification in mixed results.
162+CLI compact partial summaries expose the known bit; MCP lifecycle diagnostics
163+keep their existing `code` and add canonical `error_class` and `http_attempted`.
164+Startup configuration-read cancellation remains a configuration failure.
165+ 
166+Retry only after inspecting partial progress and the operation's idempotency
167+state. An HTTP attempt alone never proves that a remote write succeeded.
168+ 
144### Alias collision169### Alias collision
145 170 
146```text171```text
@@ -2629,6 +2629,9 @@ func dispatch(ctx context.Context, svc queryService, command string, args []stri
2629 }2629 }
2630 return attachSyncJob(ctx, client, job.ID, opts, stdout, stderr)2630 return attachSyncJob(ctx, client, job.ID, opts, stdout, stderr)
2631 }2631 }
2632+ // Collections in this action share attempt evidence, including an empty
2633+ // successful list before cancellation in a later collection.
2634+ ctx, _ = diagnostics.WithHTTPAttemptWitness(ctx)
2632 if syncSingleRecordRequested(opts) {2635 if syncSingleRecordRequested(opts) {
2633 if opts.input != "" && (opts.prComments || opts.comments) && syncRemoteAliasSurface(opts.input) == "pull_request" {2636 if opts.input != "" && (opts.prComments || opts.comments) && syncRemoteAliasSurface(opts.input) == "pull_request" {
2634 started := time.Now().UTC()2637 started := time.Now().UTC()
@@ -3722,10 +3725,13 @@ func mergeSyncError(existing error, result *service.SyncResourcesResult, err err
3722 if result == nil {3725 if result == nil {
3723 return existing3726 return existing
3724 }3727 }
3725- return &service.PartialSyncError{Errors: result.Failures, SuccessCount: result.SuccessCount, FailureCount: result.FailureCount}3728+ partial := &service.PartialSyncError{Errors: result.Failures, SuccessCount: result.SuccessCount, FailureCount: result.FailureCount}
3729+ partial.PreserveHTTPAttemptEvidence(existing, err)
3730+ return partial
3726}3731}
3727 3732 
3728type syncResourcesCompactSummary struct {3733type syncResourcesCompactSummary struct {
3734+ HTTPAttempted *bool `json:"http_attempted,omitempty"`
3729 Status string `json:"status"`3735 Status string `json:"status"`
3730 FailureClass string `json:"failure_class,omitempty"`3736 FailureClass string `json:"failure_class,omitempty"`
3731 SuccessCount int `json:"success_count"`3737 SuccessCount int `json:"success_count"`
@@ -3913,6 +3919,9 @@ func syncResourcesSummary(result *service.SyncResourcesResult, partial *service.
3913 summary.FailureGroups = syncFailureGroups(result.Failures)3919 summary.FailureGroups = syncFailureGroups(result.Failures)
3914 }3920 }
3915 if partial != nil {3921 if partial != nil {
3922+ if attempted, known := diagnostics.HTTPAttemptEvidence(partial); known {
3923+ summary.HTTPAttempted = &attempted
3924+ }
3916 summary.Status = "partial"3925 summary.Status = "partial"
3917 summary.Diagnostic = partial.Diagnostic3926 summary.Diagnostic = partial.Diagnostic
3918 summary.TotalRequested = partial.TotalRequested3927 summary.TotalRequested = partial.TotalRequested
@@ -3987,6 +3996,9 @@ func renderSyncResourcesSummaryText(w io.Writer, summary syncResourcesCompactSum
3987 summary.ZeroDeltaCount,3996 summary.ZeroDeltaCount,
3988 summary.Elapsed,3997 summary.Elapsed,
3989 )3998 )
3999+ if summary.HTTPAttempted != nil {
4000+ fmt.Fprintf(w, " http_attempted=%t", *summary.HTTPAttempted)
4001+ }
3990 if summary.PagesListed > 0 || summary.RecordsListed > 0 || summary.SkippedByWatermark > 0 {4002 if summary.PagesListed > 0 || summary.RecordsListed > 0 || summary.SkippedByWatermark > 0 {
3991 fmt.Fprintf(w, " pages_listed=%d records_listed=%d skipped_by_watermark=%d", summary.PagesListed, summary.RecordsListed, summary.SkippedByWatermark)4003 fmt.Fprintf(w, " pages_listed=%d records_listed=%d skipped_by_watermark=%d", summary.PagesListed, summary.RecordsListed, summary.SkippedByWatermark)
3992 }4004 }
@@ -4447,6 +4459,9 @@ func writeCommandError(stderr io.Writer, format string, plan startupPlan, err er
4447 if errors.As(err, &lockErr) {4459 if errors.As(err, &lockErr) {
4448 publicErr = lockErr4460 publicErr = lockErr
4449 }4461 }
4462+ if attempted, known := diagnostics.HTTPAttemptEvidence(err); known {
4463+ publicErr = diagnostics.RetainHTTPAttempt(publicErr, attempted)
4464+ }
4450 message := config.RedactDiagnostic(publicErr.Error(), config.OSSource{})4465 message := config.RedactDiagnostic(publicErr.Error(), config.OSSource{})
4451 var diagnostic diagnostics.Diagnostic4466 var diagnostic diagnostics.Diagnostic
4452 if plan.ProviderMode == "live-http" {4467 if plan.ProviderMode == "live-http" {
@@ -4595,8 +4610,11 @@ func diagnosticContext(plan startupPlan, err error) diagnostics.CommandContext {
4595 }4610 }
4596 var partialSync *service.PartialSyncError4611 var partialSync *service.PartialSyncError
4597 if errors.As(err, &partialSync) {4612 if errors.As(err, &partialSync) {
4613+ _, attemptKnown := diagnostics.HTTPAttemptEvidence(err)
4598 for _, failure := range partialSync.Errors {4614 for _, failure := range partialSync.Errors {
4599 switch failure.FailureClass {4615 switch failure.FailureClass {
4616+ case "network_timeout":
4617+ ctx.HTTPAttempted, ctx.TransportFailure = true, true
4600 case "schema_decode", "partial_response", "malformed_response", "unexpected_content_type":4618 case "schema_decode", "partial_response", "malformed_response", "unexpected_content_type":
4601 ctx.HTTPAttempted = true4619 ctx.HTTPAttempted = true
4602 ctx.SchemaDecodeFailure = true4620 ctx.SchemaDecodeFailure = true
@@ -4606,7 +4624,7 @@ func diagnosticContext(plan startupPlan, err error) diagnostics.CommandContext {
4606 switch partialSync.Diagnostic {4624 switch partialSync.Diagnostic {
4607 case service.SyncDiagnosticTimeout, service.SyncDiagnosticCancelled:4625 case service.SyncDiagnosticTimeout, service.SyncDiagnosticCancelled:
4608 ctx.HTTPAttempted = true4626 ctx.HTTPAttempted = true
4609- ctx.TransportFailure = true4627+ ctx.TransportFailure = ctx.TransportFailure || !attemptKnown
4610 ctx.FailureSource = string(partialSync.Diagnostic)4628 ctx.FailureSource = string(partialSync.Diagnostic)
4611 case service.SyncDiagnosticEmptyWiki:4629 case service.SyncDiagnosticEmptyWiki:
4612 ctx.FailureSource = string(partialSync.Diagnostic)4630 ctx.FailureSource = string(partialSync.Diagnostic)
Ainternal/cli/sync_http_attempt_test.go+96-0文件内容审核中,请稍后刷新重试
@@ -1,7 +1,9 @@
1package diagnostics1package diagnostics
2 2 
3import (3import (
4+ "context"
4 "encoding/json"5 "encoding/json"
6+ "errors"
5 "fmt"7 "fmt"
6 "net/http"8 "net/http"
7 "sort"9 "sort"
@@ -35,6 +37,8 @@ const (
35 CodeWriteAmbiguousRemote Code = "write_ambiguous_remote"37 CodeWriteAmbiguousRemote Code = "write_ambiguous_remote"
36 CodeWriteAmbiguousReadback Code = "write_ambiguous_readback_failed"38 CodeWriteAmbiguousReadback Code = "write_ambiguous_readback_failed"
37 CodeWriteConflict Code = "write_conflict"39 CodeWriteConflict Code = "write_conflict"
40+ CodeOperationCancelled Code = "operation_cancelled"
41+ CodeOperationTimeout Code = "operation_timeout"
38)42)
39 43 
40type Diagnostic struct {44type Diagnostic struct {
@@ -95,8 +99,15 @@ func (c Classifier) Classify(err error, ctx CommandContext) Diagnostic {
95 if len(ctx.SensitiveTerms) > 0 {99 if len(ctx.SensitiveTerms) > 0 {
96 filter.SensitiveTerms = append(filter.SensitiveTerms, ctx.SensitiveTerms...)100 filter.SensitiveTerms = append(filter.SensitiveTerms, ctx.SensitiveTerms...)
97 }101 }
102+ attempted, known := HTTPAttemptEvidence(err)
103+ if known {
104+ ctx.HTTPAttempted = attempted
105+ }
98 code := classifyCode(err, ctx)106 code := classifyCode(err, ctx)
99 d := Diagnostic{Code: code, Message: messageFor(code, err), ExitClass: exitClassFor(code), HTTPAttempted: httpAttemptedFor(code, ctx), Retryable: retryableFor(code) || ctx.RetryableProviderFailure}107 d := Diagnostic{Code: code, Message: messageFor(code, err), ExitClass: exitClassFor(code), HTTPAttempted: httpAttemptedFor(code, ctx), Retryable: retryableFor(code) || ctx.RetryableProviderFailure}
108+ if known {
109+ d.HTTPAttempted = attempted
110+ }
100 d.Message = filter.RedactText(d.Message)111 d.Message = filter.RedactText(d.Message)
101 d.Context = redactedContext(filter, ctx)112 d.Context = redactedContext(filter, ctx)
102 return d113 return d
@@ -156,6 +167,11 @@ func classifyCode(err error, ctx CommandContext) Code {
156 if ctx.InvalidSelectedAPIBaseURL || hasCode(err, "invalid_api_base_url") {167 if ctx.InvalidSelectedAPIBaseURL || hasCode(err, "invalid_api_base_url") {
157 return CodeConfigCredential168 return CodeConfigCredential
158 }169 }
170+ if attempted, known := HTTPAttemptEvidence(err); known && !attempted {
171+ if code := operationInterruptionCode(err); code != "" {
172+ return code
173+ }
174+ }
159 if hasCode(err, "push_mirror_sync_in_progress") && ctx.HTTPAttempted {175 if hasCode(err, "push_mirror_sync_in_progress") && ctx.HTTPAttempted {
160 return CodePushMirrorSyncInProgress176 return CodePushMirrorSyncInProgress
161 }177 }
@@ -189,12 +205,27 @@ func classifyCode(err error, ctx CommandContext) Code {
189 if hasCode(err, "schema_decode") || hasCode(err, "malformed_response") || hasCode(err, "unexpected_content_type") {205 if hasCode(err, "schema_decode") || hasCode(err, "malformed_response") || hasCode(err, "unexpected_content_type") {
190 return CodeSchemaDecode206 return CodeSchemaDecode
191 }207 }
208+ // Earlier successful HTTP must not turn a later pre-dispatch interruption
209+ // into a transport failure. Preserve confirmed provider/schema failures
210+ // above, and any actually entered failed request in a mixed aggregate.
211+ if attempted, known := HTTPTransportFailureEvidence(err); known && !attempted {
212+ if code := operationInterruptionCode(err); code != "" {
213+ return code
214+ }
215+ }
192 if (ctx.TransportFailure || hasCode(err, "live_transport_failure") || hasCode(err, "network_unavailable") || hasCode(err, "write_network_unavailable")) && ctx.HTTPAttempted {216 if (ctx.TransportFailure || hasCode(err, "live_transport_failure") || hasCode(err, "network_unavailable") || hasCode(err, "write_network_unavailable")) && ctx.HTTPAttempted {
193 return CodeLiveTransportFailure217 return CodeLiveTransportFailure
194 }218 }
195 if ctx.HTTPStatus >= 500 && ctx.HTTPAttempted {219 if ctx.HTTPStatus >= 500 && ctx.HTTPAttempted {
196 return CodeLiveTransportFailure220 return CodeLiveTransportFailure
197 }221 }
222+ // Preserve confirmed provider/schema failures in mixed partial results.
223+ // A later local interruption is not itself an HTTP transport failure.
224+ if attempted, known := HTTPAttemptEvidence(err); known && attempted {
225+ if code := operationInterruptionCode(err); code != "" {
226+ return code
227+ }
228+ }
198 if ctx.FixtureFallbackSentinel || hasCode(err, "fixture_fallback_detected") || hasCode(err, "write_fixture_fallback_detected") || hasCode(err, "fixture_read_only") || hasCode(err, "write_fixture_read_only") {229 if ctx.FixtureFallbackSentinel || hasCode(err, "fixture_fallback_detected") || hasCode(err, "write_fixture_fallback_detected") || hasCode(err, "fixture_read_only") || hasCode(err, "write_fixture_read_only") {
199 return CodeFixtureFallbackDetected230 return CodeFixtureFallbackDetected
200 }231 }
@@ -207,6 +238,21 @@ func classifyCode(err error, ctx CommandContext) Code {
207 return CodeConfigurationError238 return CodeConfigurationError
208}239}
209 240 
241+func operationInterruptionCode(err error) Code {
242+ var interrupted interface{ OperationInterruption() string }
243+ interruption := ""
244+ if errors.As(err, &interrupted) {
245+ interruption = interrupted.OperationInterruption()
246+ }
247+ if errors.Is(err, context.Canceled) || interruption == "cancelled" {
248+ return CodeOperationCancelled
249+ }
250+ if errors.Is(err, context.DeadlineExceeded) || interruption == "timeout" {
251+ return CodeOperationTimeout
252+ }
253+ return ""
254+}
255+ 
210func codeFromError(err error) Code {256func codeFromError(err error) Code {
211 for _, code := range diagnosticCodes(err) {257 for _, code := range diagnosticCodes(err) {
212 switch code {258 switch code {
@@ -330,6 +376,8 @@ func exitClassFor(code Code) string {
330 return "configuration"376 return "configuration"
331 case CodeLiveTransportFailure:377 case CodeLiveTransportFailure:
332 return "transport"378 return "transport"
379+ case CodeOperationCancelled, CodeOperationTimeout:
380+ return "operation"
333 case CodeLiveAPIFailure, CodeAPIFailure, CodePushMirrorSyncInProgress:381 case CodeLiveAPIFailure, CodeAPIFailure, CodePushMirrorSyncInProgress:
334 return "provider"382 return "provider"
335 case CodePRReviewAnchorMismatch, CodeWriteConfirmationMissing, CodeWriteAmbiguousRemote, CodeWriteAmbiguousReadback, CodeWriteConflict:383 case CodePRReviewAnchorMismatch, CodeWriteConfirmationMissing, CodeWriteAmbiguousRemote, CodeWriteAmbiguousReadback, CodeWriteConflict:
@@ -366,6 +414,10 @@ func messageFor(code Code, err error) string {
366 base += ": live provider rejected credentials"414 base += ": live provider rejected credentials"
367 case CodeLiveTransportFailure:415 case CodeLiveTransportFailure:
368 base += ": live provider request failed before a valid API response"416 base += ": live provider request failed before a valid API response"
417+ case CodeOperationCancelled:
418+ base += ": operation cancelled"
419+ case CodeOperationTimeout:
420+ base += ": operation deadline expired"
369 case CodeLiveAPIFailure:421 case CodeLiveAPIFailure:
370 base += ": live provider returned an API failure"422 base += ": live provider returned an API failure"
371 case CodeAPIFailure:423 case CodeAPIFailure:
Ainternal/diagnostics/http_attempt.go+130-0文件内容审核中,请稍后刷新重试
Ainternal/diagnostics/http_attempt_test.go+145-0文件内容审核中,请稍后刷新重试
Minternal/gitcode/errors.go+10-0文件内容审核中,请稍后刷新重试
Ainternal/gitcode/http_attempt_test.go+164-0文件内容审核中,请稍后刷新重试
@@ -1264,7 +1264,11 @@ func (c *HTTPClient) listWikiEntries(ctx context.Context, owner, repo, dir strin
1264 resp, err := c.do(ctx, http.MethodGet, endpoint, nil, nil, requestOptions{metricAttempt: observation})1264 resp, err := c.do(ctx, http.MethodGet, endpoint, nil, nil, requestOptions{metricAttempt: observation})
1265 if err != nil {1265 if err != nil {
1266 observation.finish(0, err)1266 observation.finish(0, err)
1267- return nil, ErrNetworkUnavailable{Endpoint: endpoint, Attempts: 1, Cause: err}1267+ attempts := 0
1268+ if observation.httpEntered.Load() {
1269+ attempts = 1
1270+ }
1271+ return nil, ErrNetworkUnavailable{Endpoint: endpoint, Attempts: attempts, Cause: err}
1268 }1272 }
1269 1273 
1270 body, readErr := c.readBounded(resp, endpoint)1274 body, readErr := c.readBounded(resp, endpoint)
@@ -2284,12 +2288,13 @@ func (c *HTTPClient) bytesWithOptions(ctx context.Context, method, endpoint stri
2284 }2288 }
2285 var lastRetryAfter time.Duration2289 var lastRetryAfter time.Duration
2286 var rawRetryAfter string2290 var rawRetryAfter string
2291+ httpAttempts := 0
2287 for attempt := 1; attempt <= attempts; attempt++ {2292 for attempt := 1; attempt <= attempts; attempt++ {
2288 if err := ctx.Err(); err != nil {2293 if err := ctx.Err(); err != nil {
2289- return nil, nil, ErrNetworkUnavailable{Endpoint: endpoint, Attempts: attempt - 1, Cause: err}2294+ return nil, nil, ErrNetworkUnavailable{Endpoint: endpoint, Attempts: httpAttempts, Cause: err}
2290 }2295 }
2291 if err := c.waitForRateLimit(ctx, method, endpoint, attempt); err != nil {2296 if err := c.waitForRateLimit(ctx, method, endpoint, attempt); err != nil {
2292- return nil, nil, ErrNetworkUnavailable{Endpoint: endpoint, Attempts: attempt - 1, Cause: err}2297+ return nil, nil, ErrNetworkUnavailable{Endpoint: endpoint, Attempts: httpAttempts, Cause: err}
2293 }2298 }
2294 observation := newProviderMetricAttempt(ctx)2299 observation := newProviderMetricAttempt(ctx)
2295 if attempt <= observability.MaxExecutionAttempt {2300 if attempt <= observability.MaxExecutionAttempt {
@@ -2297,16 +2302,19 @@ func (c *HTTPClient) bytesWithOptions(ctx context.Context, method, endpoint stri
2297 }2302 }
2298 opts.metricAttempt = observation2303 opts.metricAttempt = observation
2299 resp, err := c.do(ctx, method, endpoint, values, bytes.NewReader(requestBody), opts)2304 resp, err := c.do(ctx, method, endpoint, values, bytes.NewReader(requestBody), opts)
2305+ if observation.httpEntered.Load() {
2306+ httpAttempts++
2307+ }
2300 if err != nil {2308 if err != nil {
2301 if ctx.Err() != nil {2309 if ctx.Err() != nil {
2302 observation.finish(0, ctx.Err())2310 observation.finish(0, ctx.Err())
2303- return nil, nil, ErrNetworkUnavailable{Endpoint: endpoint, Attempts: attempt, Cause: ctx.Err()}2311+ return nil, nil, ErrNetworkUnavailable{Endpoint: endpoint, Attempts: httpAttempts, Cause: ctx.Err()}
2304 }2312 }
2305 observation.finish(0, err)2313 observation.finish(0, err)
2306 if isRetryableTransport(err) && attempt < attempts {2314 if isRetryableTransport(err) && attempt < attempts {
2307 continue2315 continue
2308 }2316 }
2309- return nil, nil, ErrNetworkUnavailable{Endpoint: endpoint, Attempts: attempt, Cause: err}2317+ return nil, nil, ErrNetworkUnavailable{Endpoint: endpoint, Attempts: httpAttempts, Cause: err}
2310 }2318 }
2311 if resp.StatusCode == http.StatusRequestEntityTooLarge {2319 if resp.StatusCode == http.StatusRequestEntityTooLarge {
2312 resp.Body.Close()2320 resp.Body.Close()
@@ -2340,7 +2348,7 @@ func (c *HTTPClient) bytesWithOptions(ctx context.Context, method, endpoint stri
2340 resumeAt := time.Now().Add(lastRetryAfter)2348 resumeAt := time.Now().Add(lastRetryAfter)
2341 emitRateLimitEvent(ctx, RateLimitEvent{Type: RateLimitEventRetryAfterWaitStarted, Method: method, Endpoint: endpoint, Attempt: attempt, Wait: lastRetryAfter, ResumeAt: resumeAt, RawRetryAfter: rawRetryAfter})2349 emitRateLimitEvent(ctx, RateLimitEvent{Type: RateLimitEventRetryAfterWaitStarted, Method: method, Endpoint: endpoint, Attempt: attempt, Wait: lastRetryAfter, ResumeAt: resumeAt, RawRetryAfter: rawRetryAfter})
2342 if err := sleepContext(ctx, lastRetryAfter); err != nil {2350 if err := sleepContext(ctx, lastRetryAfter); err != nil {
2343- return nil, nil, ErrNetworkUnavailable{Endpoint: endpoint, Attempts: attempt, Cause: err}2351+ return nil, nil, ErrNetworkUnavailable{Endpoint: endpoint, Attempts: httpAttempts, Cause: err}
2344 }2352 }
2345 emitRateLimitEvent(ctx, RateLimitEvent{Type: RateLimitEventRetryAfterWaitCompleted, Method: method, Endpoint: endpoint, Attempt: attempt, Wait: lastRetryAfter, ResumeAt: resumeAt, RawRetryAfter: rawRetryAfter})2353 emitRateLimitEvent(ctx, RateLimitEvent{Type: RateLimitEventRetryAfterWaitCompleted, Method: method, Endpoint: endpoint, Attempt: attempt, Wait: lastRetryAfter, ResumeAt: resumeAt, RawRetryAfter: rawRetryAfter})
2346 continue2354 continue
@@ -2352,7 +2360,7 @@ func (c *HTTPClient) bytesWithOptions(ctx context.Context, method, endpoint stri
2352 if attempt < attempts {2360 if attempt < attempts {
2353 continue2361 continue
2354 }2362 }
2355- return nil, nil, ErrNetworkUnavailable{Endpoint: endpoint, Status: resp.StatusCode, Attempts: attempt}2363+ return nil, nil, ErrNetworkUnavailable{Endpoint: endpoint, Status: resp.StatusCode, Attempts: httpAttempts}
2356 case strings.Contains(endpoint, ".wiki/") && isWikiEmptyResponse(resp.StatusCode, body):2364 case strings.Contains(endpoint, ".wiki/") && isWikiEmptyResponse(resp.StatusCode, body):
2357 owner, repo := parseWikiEndpointOwnerRepo(endpoint)2365 owner, repo := parseWikiEndpointOwnerRepo(endpoint)
2358 return nil, nil, ErrEmptyWiki{Owner: owner, Repo: repo}2366 return nil, nil, ErrEmptyWiki{Owner: owner, Repo: repo}
@@ -2365,7 +2373,7 @@ func (c *HTTPClient) bytesWithOptions(ctx context.Context, method, endpoint stri
2365 return nil, nil, c.statusError(resp.StatusCode, endpoint, body, opts)2373 return nil, nil, c.statusError(resp.StatusCode, endpoint, body, opts)
2366 }2374 }
2367 }2375 }
2368- return nil, nil, ErrNetworkUnavailable{Endpoint: endpoint, Attempts: attempts}2376+ return nil, nil, ErrNetworkUnavailable{Endpoint: endpoint, Attempts: httpAttempts}
2369}2377}
2370 2378 
2371func effectiveRateLimitRetryAfter(raw string, now time.Time, attempt int) time.Duration {2379func effectiveRateLimitRetryAfter(raw string, now time.Time, attempt int) time.Duration {
@@ -2457,7 +2465,40 @@ func (c *HTTPClient) do(ctx context.Context, method, endpoint string, values url
2457 opts.beforeAttempt()2465 opts.beforeAttempt()
2458 }2466 }
2459 opts.metricAttempt.start()2467 opts.metricAttempt.start()
2460- return c.client.Do(req)2468+ // Copy only the client configuration; reuse the original transport, jar,
2469+ // redirect policy and timeout. Never mutate the shared client's transport.
2470+ client := *c.client
2471+ client.Transport = httpAttemptTransport{inner: c.client.Transport, attempt: opts.metricAttempt}
2472+ return client.Do(req)
2473+}
2474+ 
2475+type httpAttemptTransport struct {
2476+ inner http.RoundTripper
2477+ attempt *providerMetricAttempt
2478+}
2479+ 
2480+// Preserve http.Client's legacy timeout cancellation hook for transports that
2481+// implement it. Modern transports continue to use the request context.
2482+func (t httpAttemptTransport) CancelRequest(req *http.Request) {
2483+ inner := t.inner
2484+ if inner == nil {
2485+ inner = http.DefaultTransport
2486+ }
2487+ if canceller, ok := inner.(interface{ CancelRequest(*http.Request) }); ok {
2488+ canceller.CancelRequest(req)
2489+ }
2490+}
2491+ 
2492+func (t httpAttemptTransport) RoundTrip(req *http.Request) (*http.Response, error) {
2493+ inner := t.inner
2494+ if inner == nil {
2495+ inner = http.DefaultTransport
2496+ }
2497+ if t.attempt != nil {
2498+ t.attempt.httpEntered.Store(true)
2499+ }
2500+ diagnostics.RecordHTTPAttempt(req.Context())
2501+ return inner.RoundTrip(req)
2461}2502}
2462 2503 
2463func (c *HTTPClient) v4BaseURL() *url.URL {2504func (c *HTTPClient) v4BaseURL() *url.URL {
Minternal/gitcode/provider_metrics.go+7-5文件内容审核中,请稍后刷新重试
Minternal/mcp/lifecycle_tools.go+24-10文件内容审核中,请稍后刷新重试
Minternal/mcp/mcp.go+22-0文件内容审核中,请稍后刷新重试
Ainternal/mcp/sync_http_attempt_test.go+139-0文件内容审核中,请稍后刷新重试
@@ -21,6 +21,7 @@ import (
21 "gitcode-mcp/internal/buildinfo"21 "gitcode-mcp/internal/buildinfo"
22 "gitcode-mcp/internal/cache"22 "gitcode-mcp/internal/cache"
23 "gitcode-mcp/internal/config"23 "gitcode-mcp/internal/config"
24+ "gitcode-mcp/internal/diagnostics"
24 "gitcode-mcp/internal/feedback"25 "gitcode-mcp/internal/feedback"
25 "gitcode-mcp/internal/gitcode"26 "gitcode-mcp/internal/gitcode"
26 "gitcode-mcp/internal/index"27 "gitcode-mcp/internal/index"
@@ -1578,9 +1579,11 @@ func (s *Service) Index(ctx context.Context, req OperationRequest) (OperationRes
1578 return OperationResult{Command: "index", Status: "ok", ProcessedCount: processed, Evidence: "snapshot_id=" + stored.ID, GeneratedAt: s.now().UTC()}, nil1579 return OperationResult{Command: "index", Status: "ok", ProcessedCount: processed, Evidence: "snapshot_id=" + stored.ID, GeneratedAt: s.now().UTC()}, nil
1579}1580}
1580 1581 
1581-func (s *Service) SyncToCache(ctx context.Context, req SyncRequest) (SyncResult, error) {1582+func (s *Service) SyncToCache(ctx context.Context, req SyncRequest) (_ SyncResult, retErr error) {
1583+ ctx, retainAttempt := beginSyncHTTPAttempt(ctx)
1584+ defer func() { retErr = retainAttempt(retErr) }()
1582 if err := ctx.Err(); err != nil {1585 if err := ctx.Err(); err != nil {
1583- return SyncResult{}, err1586+ return SyncResult{}, diagnostics.RetainPreDispatchInterruption(err)
1584 }1587 }
1585 repoID, err := s.requireRepo(ctx, req.RepoID, "sync")1588 repoID, err := s.requireRepo(ctx, req.RepoID, "sync")
1586 if err != nil {1589 if err != nil {
@@ -1691,7 +1694,9 @@ func (s *Service) SyncToCache(ctx context.Context, req SyncRequest) (SyncResult,
1691// with structured per-resource failure details. Results always contains entries from1694// with structured per-resource failure details. Results always contains entries from
1692// successful SyncToCache calls; Failures contains entries from failed calls.1695// successful SyncToCache calls; Failures contains entries from failed calls.
1693// Callers should check PartialSyncError before using the result.1696// Callers should check PartialSyncError before using the result.
1694-func (s *Service) SyncResources(ctx context.Context, reqs []SyncRequest) (*SyncResourcesResult, error) {1697+func (s *Service) SyncResources(ctx context.Context, reqs []SyncRequest) (_ *SyncResourcesResult, retErr error) {
1698+ ctx, retainAttempt := beginSyncHTTPAttempt(ctx)
1699+ defer func() { retErr = retainAttempt(retErr) }()
1695 result := &SyncResourcesResult{1700 result := &SyncResourcesResult{
1696 Results: make([]SyncResult, 0, len(reqs)),1701 Results: make([]SyncResult, 0, len(reqs)),
1697 Failures: make([]ResourceError, 0),1702 Failures: make([]ResourceError, 0),
@@ -1699,7 +1704,7 @@ func (s *Service) SyncResources(ctx context.Context, reqs []SyncRequest) (*SyncR
1699 var partial *PartialSyncError1704 var partial *PartialSyncError
1700 for i, req := range reqs {1705 for i, req := range reqs {
1701 if err := ctx.Err(); err != nil {1706 if err := ctx.Err(); err != nil {
1702- re := newResourceErrorWithMessage(req.StableID, "", err, fmt.Sprintf("sync resources: context cancelled at request %d", i))1707+ re := newResourceErrorWithMessage(req.StableID, "", diagnostics.RetainPreDispatchInterruption(err), fmt.Sprintf("sync resources: context cancelled at request %d", i))
1703 result.Failures = append(result.Failures, re)1708 result.Failures = append(result.Failures, re)
1704 continue1709 continue
1705 }1710 }
@@ -1734,7 +1739,9 @@ func (s *Service) SyncResources(ctx context.Context, reqs []SyncRequest) (*SyncR
1734 return result, nil1739 return result, nil
1735}1740}
1736 1741 
1737-func (s *Service) BulkSyncIssues(ctx context.Context, req BulkSyncRequest) (*SyncResourcesResult, error) {1742+func (s *Service) BulkSyncIssues(ctx context.Context, req BulkSyncRequest) (_ *SyncResourcesResult, retErr error) {
1743+ ctx, retainAttempt := beginSyncHTTPAttempt(ctx)
1744+ defer func() { retErr = retainAttempt(retErr) }()
1738 if err := ctx.Err(); err != nil {1745 if err := ctx.Err(); err != nil {
1739 if req.Bounds != nil {1746 if req.Bounds != nil {
1740 diag := SyncDiagnosticCancelled1747 diag := SyncDiagnosticCancelled
@@ -1787,6 +1794,9 @@ func (s *Service) BulkSyncIssues(ctx context.Context, req BulkSyncRequest) (*Syn
1787 result.SuccessCount = len(result.Results)1794 result.SuccessCount = len(result.Results)
1788 result.FailureCount = len(result.Failures)1795 result.FailureCount = len(result.Failures)
1789 if summaryErr := s.attachIssueCommentQueueSummary(ctx, result, repoID, "parent_backfill"); summaryErr != nil {1796 if summaryErr := s.attachIssueCommentQueueSummary(ctx, result, repoID, "parent_backfill"); summaryErr != nil {
1797+ if result.FailureCount > 0 {
1798+ return result, partialSyncWithLocalCause(result, summaryErr)
1799+ }
1790 return result, summaryErr1800 return result, summaryErr
1791 }1801 }
1792 if result.FailureCount > 0 {1802 if result.FailureCount > 0 {
@@ -1870,7 +1880,7 @@ func (s *Service) bulkSyncIssuesBounded(ctx context.Context, req BulkSyncRequest
1870 result.Failures = append(result.Failures, re)1880 result.Failures = append(result.Failures, re)
1871 result.FailureCount = len(result.Failures)1881 result.FailureCount = len(result.Failures)
1872 if summaryErr := s.attachIssueCommentQueueSummary(ctx, result, req.RepoID, "parent_backfill"); summaryErr != nil {1882 if summaryErr := s.attachIssueCommentQueueSummary(ctx, result, req.RepoID, "parent_backfill"); summaryErr != nil {
1873- return result, summaryErr1883+ return result, partialSyncWithLocalCause(result, summaryErr)
1874 }1884 }
1875 s.recordSyncFrontierBestEffort(ctx, req.RepoID, "issue", result, observed)1885 s.recordSyncFrontierBestEffort(ctx, req.RepoID, "issue", result, observed)
1876 return result, &PartialSyncError{Errors: result.Failures, SuccessCount: result.SuccessCount, FailureCount: result.FailureCount, TotalRequested: totalRequested}1886 return result, &PartialSyncError{Errors: result.Failures, SuccessCount: result.SuccessCount, FailureCount: result.FailureCount, TotalRequested: totalRequested}
@@ -2036,7 +2046,9 @@ func (s *Service) prepareIssueCommentSync(ctx context.Context, graph cache.Sourc
2036 return item, issue.Comments == 0, item.Status != "complete", nil2046 return item, issue.Comments == 0, item.Status != "complete", nil
2037}2047}
2038 2048 
2039-func (s *Service) BulkSyncIssueComments(ctx context.Context, req BulkSyncRequest) (*SyncResourcesResult, error) {2049+func (s *Service) BulkSyncIssueComments(ctx context.Context, req BulkSyncRequest) (_ *SyncResourcesResult, retErr error) {
2050+ ctx, retainAttempt := beginSyncHTTPAttempt(ctx)
2051+ defer func() { retErr = retainAttempt(retErr) }()
2040 if err := ctx.Err(); err != nil {2052 if err := ctx.Err(); err != nil {
2041 return nil, err2053 return nil, err
2042 }2054 }
@@ -2707,7 +2719,9 @@ func syncResultsDeferredCount(results []SyncResult) int {
2707 return total2719 return total
2708}2720}
2709 2721 
2710-func (s *Service) BulkSyncPullRequests(ctx context.Context, req BulkSyncRequest) (*SyncResourcesResult, error) {2722+func (s *Service) BulkSyncPullRequests(ctx context.Context, req BulkSyncRequest) (_ *SyncResourcesResult, retErr error) {
2723+ ctx, retainAttempt := beginSyncHTTPAttempt(ctx)
2724+ defer func() { retErr = retainAttempt(retErr) }()
2711 if err := ctx.Err(); err != nil {2725 if err := ctx.Err(); err != nil {
2712 if req.Bounds != nil {2726 if req.Bounds != nil {
2713 diag := SyncDiagnosticCancelled2727 diag := SyncDiagnosticCancelled
@@ -3031,7 +3045,9 @@ func (s *Service) recordSyncFrontierBestEffort(ctx context.Context, repoID, remo
3031 _ = s.recordSyncFrontier(ctx, repoID, remoteType, result, high)3045 _ = s.recordSyncFrontier(ctx, repoID, remoteType, result, high)
3032}3046}
3033 3047 
3034-func (s *Service) BulkSyncPRComments(ctx context.Context, req BulkSyncRequest) (*SyncResourcesResult, error) {3048+func (s *Service) BulkSyncPRComments(ctx context.Context, req BulkSyncRequest) (_ *SyncResourcesResult, retErr error) {
3049+ ctx, retainAttempt := beginSyncHTTPAttempt(ctx)
3050+ defer func() { retErr = retainAttempt(retErr) }()
3035 remoteAlias := strings.TrimSpace(req.RemoteAlias)3051 remoteAlias := strings.TrimSpace(req.RemoteAlias)
3036 targetedPRNumber := 03052 targetedPRNumber := 0
3037 if remoteAlias != "" {3053 if remoteAlias != "" {
@@ -3402,7 +3418,9 @@ func (s *Service) stagePRCommentPage(ctx context.Context, req BulkSyncRequest, p
3402 }3418 }
3403}3419}
3404 3420 
3405-func (s *Service) BulkSyncWiki(ctx context.Context, req BulkSyncRequest) (*SyncResourcesResult, error) {3421+func (s *Service) BulkSyncWiki(ctx context.Context, req BulkSyncRequest) (_ *SyncResourcesResult, retErr error) {
3422+ ctx, retainAttempt := beginSyncHTTPAttempt(ctx)
3423+ defer func() { retErr = retainAttempt(retErr) }()
3406 if err := ctx.Err(); err != nil {3424 if err := ctx.Err(); err != nil {
3407 if req.Bounds != nil {3425 if req.Bounds != nil {
3408 diag := SyncDiagnosticCancelled3426 diag := SyncDiagnosticCancelled
@@ -3507,9 +3525,9 @@ func (s *Service) bulkSyncWikiBounded(ctx context.Context, req BulkSyncRequest,
3507 result.SuccessCount = len(result.Results)3525 result.SuccessCount = len(result.Results)
3508 result.FailureCount = len(result.Failures)3526 result.FailureCount = len(result.Failures)
3509 if result.SuccessCount == 0 && result.FailureCount == 0 {3527 if result.SuccessCount == 0 && result.FailureCount == 0 {
3510- return nil, &PartialSyncError{Errors: nil, SuccessCount: 0, FailureCount: 0, Diagnostic: diag, TotalRequested: totalRequested}3528+ return nil, &PartialSyncError{Errors: nil, SuccessCount: 0, FailureCount: 0, Diagnostic: diag, TotalRequested: totalRequested, retainedCause: err}
3511 }3529 }
3512- return result, &PartialSyncError{Errors: result.Failures, SuccessCount: result.SuccessCount, FailureCount: result.FailureCount, Diagnostic: diag, TotalRequested: totalRequested}3530+ return result, &PartialSyncError{Errors: result.Failures, SuccessCount: result.SuccessCount, FailureCount: result.FailureCount, Diagnostic: diag, TotalRequested: totalRequested, retainedCause: err}
3513 }3531 }
3514 result.SuccessCount = len(result.Results)3532 result.SuccessCount = len(result.Results)
3515 result.FailureCount = len(result.Failures)3533 result.FailureCount = len(result.Failures)
@@ -3693,7 +3711,9 @@ func (s *Service) skipWikiListItemByRevision(ctx context.Context, req SyncReques
3693 return SyncResult{IdempotencyKey: req.IdempotencyKey, Status: "succeeded", Counts: counts, SyncEventID: eventID, Freshness: string(FreshnessFresh), Record: sourceSummary(stored), GeneratedAt: completedAt, StartedAt: completedAt, CompletedAt: completedAt, ZeroDelta: true}, true, nil3711 return SyncResult{IdempotencyKey: req.IdempotencyKey, Status: "succeeded", Counts: counts, SyncEventID: eventID, Freshness: string(FreshnessFresh), Record: sourceSummary(stored), GeneratedAt: completedAt, StartedAt: completedAt, CompletedAt: completedAt, ZeroDelta: true}, true, nil
3694}3712}
3695 3713 
3696-func (s *Service) BulkSyncAll(ctx context.Context, req BulkSyncRequest) (*SyncResourcesResult, error) {3714+func (s *Service) BulkSyncAll(ctx context.Context, req BulkSyncRequest) (_ *SyncResourcesResult, retErr error) {
3715+ ctx, retainAttempt := beginSyncHTTPAttempt(ctx)
3716+ defer func() { retErr = retainAttempt(retErr) }()
3697 if err := ctx.Err(); err != nil {3717 if err := ctx.Err(); err != nil {
3698 return nil, err3718 return nil, err
3699 }3719 }
@@ -3746,12 +3766,14 @@ func (s *Service) BulkSyncAll(ctx context.Context, req BulkSyncRequest) (*SyncRe
3746 aggregated.FailureCount = len(aggregated.Failures)3766 aggregated.FailureCount = len(aggregated.Failures)
3747 3767 
3748 if issuesErr != nil || wikiErr != nil {3768 if issuesErr != nil || wikiErr != nil {
3749- return aggregated, &PartialSyncError{3769+ partial := &PartialSyncError{
3750 Errors: aggregated.Failures,3770 Errors: aggregated.Failures,
3751 SuccessCount: aggregated.SuccessCount,3771 SuccessCount: aggregated.SuccessCount,
3752 FailureCount: aggregated.FailureCount,3772 FailureCount: aggregated.FailureCount,
3753 Diagnostic: firstDiagnostic,3773 Diagnostic: firstDiagnostic,
3754 }3774 }
3775+ partial.PreserveHTTPAttemptEvidence(issuesErr, wikiErr)
3776+ return aggregated, partial
3755 }3777 }
3756 if aggregated.FailureCount > 0 {3778 if aggregated.FailureCount > 0 {
3757 return aggregated, &PartialSyncError{3779 return aggregated, &PartialSyncError{
Ainternal/service/sync_http_attempt.go+73-0文件内容审核中,请稍后刷新重试
Ainternal/service/sync_http_attempt_test.go+302-0文件内容审核中,请稍后刷新重试
@@ -668,11 +668,14 @@ func resourceStatusCode(err error) int {
668}668}
669 669 
670type PartialSyncError struct {670type PartialSyncError struct {
671- Errors []ResourceError `json:"errors"`671+ httpAttempted bool
672- SuccessCount int `json:"success_count"`672+ httpAttemptKnown bool
673- FailureCount int `json:"failure_count"`673+ retainedCause error
674- Diagnostic SyncDiagnostic `json:"diagnostic,omitempty"`674+ Errors []ResourceError `json:"errors"`
675- TotalRequested int `json:"total_requested,omitempty"`675+ SuccessCount int `json:"success_count"`
676+ FailureCount int `json:"failure_count"`
677+ Diagnostic SyncDiagnostic `json:"diagnostic,omitempty"`
678+ TotalRequested int `json:"total_requested,omitempty"`
676}679}
677 680 
678func (e PartialSyncError) Error() string {681func (e PartialSyncError) Error() string {
@@ -686,6 +689,9 @@ func (e PartialSyncError) Unwrap() []error {
686 out = append(out, resourceErr.Err)689 out = append(out, resourceErr.Err)
687 }690 }
688 }691 }
692+ if e.retainedCause != nil {
693+ out = append(out, e.retainedCause)
694+ }
689 return out695 return out
690}696}
691 697