bootstrap: clearer messaging when a push times out · Entire
bootstrap: clearer messaging when a push times out
bfc5a85→main·
Soph·1mo ago·2 files·+78 added/-4 removed
Distinguish a timeout from a size rejection in the auto-batch notice
("target push timed out" vs "target rejected pack") so the user sees why
the retry kicked in.
When a one-shot push hits a batchable failure (408/504/413) but no
batched fallback is possible — the source can't serve the protocol-v2
fetch filter checkpointing needs — wrap the error with guidance instead
of surfacing a bare "http 408", while still wrapping the original error
for callers that inspect it.
Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
Sessions
27f9d562ca0cView transcript
[?
can you take a look at https://github.com/entireio/git-sync/issues/70Claude Code·Opus 4.8[1m]·1 step](/content/gh/entireio/git-sync/session/fddf9dc4-36f4-4c09-b698-3a0e976d3f4a#timeline-27f9d562ca0c/index.html)
Changes
2
internal/strategy/bootstrap
Mbootstrap.go+27/-4
Mbootstrap_test.go+51
178 unmodified lines
179
180
181
182
182
183
184
185
186
187
188
185
186
187
189
190
191
192
193
194
1279 unmodified lines
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
178 unmodified lines
if pushErr != nil {
autoBatch, ok := autoTargetMaxPackBytes(p, pushErr)
if !ok {
return result, fmt.Errorf("push target refs: %w", pushErr)
return result, fmt.Errorf("push target refs: %w", actionableTargetPushError(p, pushErr))
}
reason := "target rejected pack"
if isTargetPushDeadlineError(pushErr) {
reason = "target push timed out"
}
p.log("bootstrap retrying with batched mode after target rejection",
"target_max_pack_bytes", autoBatch)
p.notice(fmt.Sprintf("target rejected pack — switching to batched mode (limit %s)",
humanBytes(autoBatch)))
"target_max_pack_bytes", autoBatch, "reason", reason)
p.notice(fmt.Sprintf("%s — switching to batched mode (limit %s)",
reason, humanBytes(autoBatch)))
p.TargetMaxPack = autoBatch
return executeBatched(ctx, p, plans, result)
}
1279 unmodified lines
return isTargetBodyLimitError(err) || isTargetPushDeadlineError(err)
}
// actionableTargetPushError augments a one-shot push failure with guidance
// when the target rejected the pack for being too large or slow but batched
// bootstrap couldn't take over — which, on the one-shot path, means the source
// can't serve the protocol-v2 fetch filter that checkpointing requires. The
// extra context tells the user why the obvious knob (--target-max-pack-bytes)
// won't help here, instead of leaving a bare "http 408". Returns err unchanged
// for non-batchable failures or when batching is in fact available.
func actionableTargetPushError(p Params, err error) error {
if !isBatchableTargetPushError(err) {
return err
}
if p.SourceService != nil && p.SourceService.SupportsBootstrapBatch() {
return err
}
return fmt.Errorf("%w (target rejected the pack as too large or too slow to receive; "+
"batched bootstrap could split it into smaller pushes, but the source does not "+
"support the protocol-v2 fetch filter batched bootstrap requires)", err)
}
func targetBodyLimit(err error) int64 {
if err == nil {
return 0
Minternal/strategy/bootstrap/bootstrap.go+27/-4
1495 unmodified lines
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1495 unmodified lines
}
}
// noBatchSource is a source that can't serve the protocol-v2 fetch filter
// batched bootstrap needs, so a one-shot push failure has no batched fallback.
type noBatchSource struct{ fakeBootstrapSource }
func (noBatchSource) SupportsBootstrapBatch() bool { return false }
func TestAutoTargetMaxPackBytesTimeoutTriggersBatching(t *testing.T) {
limit, ok := autoTargetMaxPackBytes(
Params{SourceService: fakeBootstrapSource{}},
errors.New("target receive-pack: http 408: request timeout"),
)
if !ok {
t.Fatal("autoTargetMaxPackBytes(408) = not ok, want batched fallback")
}
if limit != defaultTargetMaxPackBytes {
t.Fatalf("limit = %d, want default %d", limit, int64(defaultTargetMaxPackBytes))
}
}
func TestExecuteOneShotTimeoutWithoutBatchSupportIsActionable(t *testing.T) {
mainRef := plumbing.NewBranchReferenceName("main")
mainHash := plumbing.NewHash("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
pushErr := errors.New("target receive-pack: post RPC stream body: http 408: request timeout")
_, err := Execute(context.Background(), Params{
SourceService: noBatchSource{fakeBootstrapSource{
fetchPack: func(_ context.Context, _ gitproto.Conn, _ map[plumbing.ReferenceName]gitproto.DesiredRef, _ map[plumbing.ReferenceName]plumbing.Hash) (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader([]byte("PACK"))), nil
},
}},
TargetPusher: fakeBootstrapPusher{
pushPack: func(_ context.Context, _ []gitproto.PushCommand, pack io.ReadCloser) error {
_ = pack.Close()
return pushErr
},
},
DesiredRefs: map[plumbing.ReferenceName]planner.DesiredRef{
mainRef: {SourceRef: mainRef, TargetRef: mainRef, SourceHash: mainHash, Kind: planner.RefKindBranch},
},
}, "empty target")
if err == nil {
t.Fatal("Execute() error = nil, want actionable timeout error")
}
if !errors.Is(err, pushErr) {
t.Fatalf("Execute() error does not wrap original push error: %v", err)
}
if !strings.Contains(err.Error(), "protocol-v2 fetch filter") {
t.Fatalf("Execute() error missing batched-bootstrap guidance: %v", err)
}
}
func TestExecuteBatchedClosesCheckpointPackOnPushError(t *testing.T) {
mainRef := plumbing.NewBranchReferenceName("main")
hashes := makeLinearCommitChain(t, 1)