已合并
Report actual sync HTTP attempt evidence #174
urandon创建于 24 天前
Report actual sync HTTP attempt evidence #174
已合并
共 19 个文件变更+1468-45
| @@ -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 collision | 169 | ### Alias collision |
| 145 | 170 | ||
| 146 | ```text | 171 | ```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 existing | 3726 | 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 | ||
| 3728 | type syncResourcesCompactSummary struct { | 3733 | type 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.Diagnostic | 3926 | summary.Diagnostic = partial.Diagnostic |
| 3918 | summary.TotalRequested = partial.TotalRequested | 3927 | 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 = lockErr | 4460 | 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.Diagnostic | 4466 | 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.PartialSyncError | 4611 | 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 = true | 4619 | ctx.HTTPAttempted = true |
| 4602 | ctx.SchemaDecodeFailure = true | 4620 | 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 = true | 4626 | ctx.HTTPAttempted = true |
| 4609 | - ctx.TransportFailure = true | 4627 | + 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) |
| @@ -1,7 +1,9 @@ | |||
| 1 | package diagnostics | 1 | package diagnostics |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 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 | ||
| 40 | type Diagnostic struct { | 44 | type 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 d | 113 | 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 CodeConfigCredential | 168 | 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 CodePushMirrorSyncInProgress | 176 | 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 CodeSchemaDecode | 206 | 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 CodeLiveTransportFailure | 217 | return CodeLiveTransportFailure |
| 194 | } | 218 | } |
| 195 | if ctx.HTTPStatus >= 500 && ctx.HTTPAttempted { | 219 | if ctx.HTTPStatus >= 500 && ctx.HTTPAttempted { |
| 196 | return CodeLiveTransportFailure | 220 | 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 CodeFixtureFallbackDetected | 230 | return CodeFixtureFallbackDetected |
| 200 | } | 231 | } |
| @@ -207,6 +238,21 @@ func classifyCode(err error, ctx CommandContext) Code { | |||
| 207 | return CodeConfigurationError | 238 | 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 | + | ||
| 210 | func codeFromError(err error) Code { | 256 | func 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: |
| @@ -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.Duration | 2289 | var lastRetryAfter time.Duration |
| 2286 | var rawRetryAfter string | 2290 | 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 = observation | 2303 | 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 | continue | 2315 | 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 | continue | 2354 | continue |
| @@ -2352,7 +2360,7 @@ func (c *HTTPClient) bytesWithOptions(ctx context.Context, method, endpoint stri | |||
| 2352 | if attempt < attempts { | 2360 | if attempt < attempts { |
| 2353 | continue | 2361 | 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 | ||
| 2371 | func effectiveRateLimitRetryAfter(raw string, now time.Time, attempt int) time.Duration { | 2379 | func 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 | ||
| 2463 | func (c *HTTPClient) v4BaseURL() *url.URL { | 2504 | func (c *HTTPClient) v4BaseURL() *url.URL { |
| @@ -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()}, nil | 1579 | 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{}, err | 1586 | + 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 from | 1694 | // 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 *PartialSyncError | 1704 | 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 | continue | 1709 | continue |
| 1705 | } | 1710 | } |
| @@ -1734,7 +1739,9 @@ func (s *Service) SyncResources(ctx context.Context, reqs []SyncRequest) (*SyncR | |||
| 1734 | return result, nil | 1739 | 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 := SyncDiagnosticCancelled | 1747 | 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, summaryErr | 1800 | 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, summaryErr | 1883 | + 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", nil | 2046 | 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, err | 2053 | return nil, err |
| 2042 | } | 2054 | } |
| @@ -2707,7 +2719,9 @@ func syncResultsDeferredCount(results []SyncResult) int { | |||
| 2707 | return total | 2719 | 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 := SyncDiagnosticCancelled | 2727 | 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 := 0 | 3052 | 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 := SyncDiagnosticCancelled | 3426 | 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, nil | 3711 | 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, err | 3718 | 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{ |
| @@ -668,11 +668,14 @@ func resourceStatusCode(err error) int { | |||
| 668 | } | 668 | } |
| 669 | 669 | ||
| 670 | type PartialSyncError struct { | 670 | type 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 | ||
| 678 | func (e PartialSyncError) Error() string { | 681 | func (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 out | 695 | return out |
| 690 | } | 696 | } |
| 691 | 697 | ||