Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions internal/strategy/git/export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,11 @@ func ColdPreparationQueueLimitForTest() int {
func (s *Strategy) ClaimSnapshotForTest(upstream string) (bool, error) {
return s.snapshotCoord.Claim(snapshotJobBase, upstream, 0)
}

// HoldUploadPackCloneSlotForTest acquires a clone-shaped upload-pack slot
// and returns a release func. admitted is false if the limiter rejected or
// the context was cancelled.
func (s *Strategy) HoldUploadPackCloneSlotForTest(ctx context.Context, repo string) (release func(), admitted bool) {
release, result, _ := s.uploadPackLimiter.acquire(ctx, repo)
return release, result == acquireOK
}
41 changes: 30 additions & 11 deletions internal/strategy/git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@ type Config struct {
BundleCacheTTL time.Duration `hcl:"bundle-cache-ttl,optional" help:"TTL of cached server-side git bundles." default:"2h"`

SnapshotFilters map[string]string `hcl:"snapshot-filters,optional" help:"Per-repository git partial-clone filter applied to workstation snapshots, keyed by host/org/repo (e.g. {\"github.com/org/repo\": \"blob:none\"}). Filtered snapshots contain full history metadata but only the blobs needed for the HEAD checkout; clients lazily fetch historical blobs through cachew on demand."`

// Clone-shaped git-upload-pack gating. Incremental fetches (pkt-lines
// containing "have ") are never limited. 0 concurrency is unlimited and
// matches historical behaviour.
UploadPackCloneConcurrency int `hcl:"upload-pack-clone-concurrency,optional" help:"Max concurrent clone-shaped git-upload-pack responses (no 'have' lines). 0 is unlimited." default:"0"`
UploadPackClonePerRepoConcurrency int `hcl:"upload-pack-clone-per-repo-concurrency,optional" help:"Max concurrent clone-shaped git-upload-pack responses per repository. 0 is unlimited." default:"0"`
UploadPackCloneQueueTimeout time.Duration `hcl:"upload-pack-clone-queue-timeout,optional" help:"How long a clone-shaped upload-pack may wait for a slot before 503. 0 fails immediately when at capacity." default:"0"`
UploadPackCloneRetryAfter time.Duration `hcl:"upload-pack-clone-retry-after,optional" help:"Retry-After value sent with 503 overload responses." default:"30s"`
}

type Strategy struct {
Expand All @@ -75,6 +83,7 @@ type Strategy struct {
mirrorPreparations sync.Map // One entry per upstream URL prevents duplicate preparation jobs.
deferredRestoreOnce sync.Map // keyed by upstream URL, ensures at most one deferred restore per repo
metrics *gitMetrics
uploadPackLimiter *uploadPackLimiter
repoCounts *RepoCounts
snapshotCoord *SnapshotCoordinator
coldPreparationDelay func() time.Duration
Expand Down Expand Up @@ -141,16 +150,17 @@ func New(
m := newGitMetrics()

s := &Strategy{
config: config,
cache: cache,
cloneManager: cloneManager,
httpClient: http.DefaultClient,
ctx: ctx,
scheduler: scheduler.WithQueuePrefix("git"),
spools: make(map[string]*RepoSpools),
tokenManager: tokenManager,
metrics: m,
metadataWired: make(chan struct{}),
config: config,
cache: cache,
cloneManager: cloneManager,
httpClient: http.DefaultClient,
ctx: ctx,
scheduler: scheduler.WithQueuePrefix("git"),
spools: make(map[string]*RepoSpools),
tokenManager: tokenManager,
metrics: m,
uploadPackLimiter: newUploadPackLimiter(config),
metadataWired: make(chan struct{}),
coldPreparationDelay: func() time.Duration {
return rand.N(coldPreparationSpread) //nolint:gosec // The delay does not protect sensitive data.
},
Expand Down Expand Up @@ -221,7 +231,11 @@ func New(
mux.Handle("GET /git/{host}/{path...}", http.HandlerFunc(s.handleRequest))
mux.Handle("POST /git/{host}/{path...}", http.HandlerFunc(s.handleRequest))

logger.InfoContext(ctx, "Git strategy initialized", "snapshot_interval", config.SnapshotInterval)
logger.InfoContext(ctx, "Git strategy initialized",
"snapshot_interval", config.SnapshotInterval,
"upload_pack_clone_concurrency", config.UploadPackCloneConcurrency,
"upload_pack_clone_per_repo_concurrency", config.UploadPackClonePerRepoConcurrency,
"upload_pack_clone_queue_timeout", config.UploadPackCloneQueueTimeout)

return s, nil
}
Expand Down Expand Up @@ -387,6 +401,11 @@ func (s *Strategy) handleGitRequest(w http.ResponseWriter, r *http.Request, host
if err := s.repoCounts.IncrementClone(upstreamURL); err != nil {
logger.WarnContext(ctx, "Failed to increment repo clone count", "error", err)
}
release, admitted := s.gateCloneUploadPack(w, r, upstreamURL)
if !admitted {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 B1 (blocker for enabling in prod) + M3/M4: RequestIsClone treats any upload-pack POST without have as a clone. Git's promisor lazy fetch runs git -c fetch.negotiationAlgorithm=noop fetch … --filter=blob:none --stdin (upstream promisor-remote.c:47-50), i.e. wants + filter blob:none + done and no haves. Prod snapshot-filters applies blob:none to squareup/java, ios-register, cash-ios, so every workstation git log -p / blame / checkout <old> on those repos issues such POSTs; under per-repo = 2 they compete with 200–760s full clones and will mostly 503, failing the user's git command. Repro: v2 body command=fetch … want <oid> filter blob:none donetrue. Also (M4) the detector fails closed when the 64 KiB inspect window is exhausted without seeing have — 1500 wants + 1 have (75 KB) → true. Options: exempt bodies containing a filter line, or gate the expensive stage via uploadpack.packObjectsHook (sees the resolved want set), or cat-file --batch-check the wants and exempt non-commit wants; and fail open at the inspect limit.

Placement (M3/m6): this runs before the switch state, so it also gates — and 503s — the not our ref fallback and the StateEmpty/StateCloning spool/forward paths, which are GitHub-bound and cheap for Cachew, and a rejected StateEmpty request never reaches the scheduler.Submit("clone") at L424. Production telemetry for the 2026-09-16 window shows Cachew emitted 0 POST 503s on /git (503s: 20, all GET; POST 502s: 66 = Upstream request failed (context canceled) ×66, plus unexpected EOF ×37 and Falling back to upstream due to 'not our ref' for java/go-square ×11) — i.e. the failures Cachew recorded were upstream-proxied fetches, not local pack generation. Worth verifying the root cause with server-side status metrics before tuning the HCL.

return
}
defer release()
}

state := repo.State()
Expand Down
92 changes: 56 additions & 36 deletions internal/strategy/git/metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,47 +13,51 @@ import (
)

type gitMetrics struct {
operationDuration metric.Float64Histogram
operationTotal metric.Int64Counter
requestTotal metric.Int64Counter
snapshotServeTotal metric.Int64Counter
snapshotServeSize metric.Float64Histogram
snapshotServeDuration metric.Float64Histogram
bundleServeTotal metric.Int64Counter
bundleServeSize metric.Float64Histogram
bundleServeDuration metric.Float64Histogram
ensureRefsTotal metric.Int64Counter
ensureRefsDuration metric.Float64Histogram
spoolWriterDuration metric.Float64Histogram
spoolFollowerWaitTotal metric.Int64Counter
spoolFollowerWait metric.Float64Histogram
repackPackCount metric.Float64Histogram
snapshotServeBandwidth metric.Float64Histogram
lfsPhaseDuration metric.Float64Histogram
lfsPhaseBytes metric.Float64Histogram
operationDuration metric.Float64Histogram
operationTotal metric.Int64Counter
requestTotal metric.Int64Counter
snapshotServeTotal metric.Int64Counter
snapshotServeSize metric.Float64Histogram
snapshotServeDuration metric.Float64Histogram
bundleServeTotal metric.Int64Counter
bundleServeSize metric.Float64Histogram
bundleServeDuration metric.Float64Histogram
ensureRefsTotal metric.Int64Counter
ensureRefsDuration metric.Float64Histogram
spoolWriterDuration metric.Float64Histogram
spoolFollowerWaitTotal metric.Int64Counter
spoolFollowerWait metric.Float64Histogram
repackPackCount metric.Float64Histogram
snapshotServeBandwidth metric.Float64Histogram
lfsPhaseDuration metric.Float64Histogram
lfsPhaseBytes metric.Float64Histogram
uploadPackCloneGateTotal metric.Int64Counter
uploadPackCloneQueueWait metric.Float64Histogram
}

func newGitMetrics() *gitMetrics {
meter := otel.Meter("cachew.git")
return &gitMetrics{
operationDuration: metrics.NewHistogram(meter, "cachew.git.operation_duration_seconds", "s", "Duration of git operations (clone, fetch, repack, snapshot)", metrics.LatencyBuckets()),
operationTotal: metrics.NewMetric[metric.Int64Counter](meter, "cachew.git.operations_total", "{operations}", "Total number of git operations"),
requestTotal: metrics.NewMetric[metric.Int64Counter](meter, "cachew.git.requests_total", "{requests}", "Total number of git HTTP requests by type"),
snapshotServeTotal: metrics.NewMetric[metric.Int64Counter](meter, "cachew.git.snapshot_serves_total", "{serves}", "Snapshot serve events by source (cache, spool, cold_cache, generated) and repository"),
snapshotServeSize: metrics.NewHistogram(meter, "cachew.git.snapshot_serve_bytes", "By", "Size of served snapshots in bytes", metrics.ByteBuckets()),
snapshotServeDuration: metrics.NewHistogram(meter, "cachew.git.snapshot_serve_duration_seconds", "s", "Wall-clock duration of snapshot serves, from handler entry to last byte sent", metrics.LatencyBuckets()),
bundleServeTotal: metrics.NewMetric[metric.Int64Counter](meter, "cachew.git.bundle_serves_total", "{serves}", "Bundle serve events by source (cache, generated, up_to_date, miss_bad_base, miss) and repository"),
bundleServeSize: metrics.NewHistogram(meter, "cachew.git.bundle_serve_bytes", "By", "Size of served bundles in bytes", metrics.ByteBuckets()),
bundleServeDuration: metrics.NewHistogram(meter, "cachew.git.bundle_serve_duration_seconds", "s", "Wall-clock duration of bundle serves, including any on-demand generation", metrics.LatencyBuckets()),
ensureRefsTotal: metrics.NewMetric[metric.Int64Counter](meter, "cachew.git.ensure_refs_total", "{requests}", "EnsureRefs requests by fetched and status"),
ensureRefsDuration: metrics.NewHistogram(meter, "cachew.git.ensure_refs_duration_seconds", "s", "Duration of EnsureRefs requests, including any upstream fetch", metrics.FastLatencyBuckets()),
spoolWriterDuration: metrics.NewHistogram(meter, "cachew.git.spool_writer_duration_seconds", "s", "Time the snapshot spool writer spent producing the stream", metrics.LatencyBuckets()),
spoolFollowerWaitTotal: metrics.NewMetric[metric.Int64Counter](meter, "cachew.git.spool_follower_waits_total", "{waits}", "Snapshot spool follower events, by outcome (served, writer_failed)"),
spoolFollowerWait: metrics.NewHistogram(meter, "cachew.git.spool_follower_wait_seconds", "s", "Time a snapshot spool follower spent waiting for the writer to publish headers", metrics.FastLatencyBuckets()),
repackPackCount: metrics.NewHistogram(meter, "cachew.git.repack_pack_count", "{packs}", "Pack file count observed before and after repack, by stage (before, after)", metrics.SmallCountBuckets()),
snapshotServeBandwidth: metrics.NewHistogram(meter, "cachew.git.snapshot_serve_bandwidth_mbps", "MiBy/s", "Per-request snapshot serve throughput in MiB/s, by source and repository", metrics.BandwidthMbpsBuckets()),
lfsPhaseDuration: metrics.NewHistogram(meter, "cachew.git.lfs_phase_duration_seconds", "s", "Duration of an LFS-snapshot generation phase (discover, clone, fetch, archive_upload), by status and repository", metrics.LatencyBuckets()),
lfsPhaseBytes: metrics.NewHistogram(meter, "cachew.git.lfs_phase_bytes", "By", "Bytes processed in an LFS-snapshot generation phase, by phase and repository (e.g. .git/lfs size after fetch)", metrics.ByteBuckets()),
operationDuration: metrics.NewHistogram(meter, "cachew.git.operation_duration_seconds", "s", "Duration of git operations (clone, fetch, repack, snapshot)", metrics.LatencyBuckets()),
operationTotal: metrics.NewMetric[metric.Int64Counter](meter, "cachew.git.operations_total", "{operations}", "Total number of git operations"),
requestTotal: metrics.NewMetric[metric.Int64Counter](meter, "cachew.git.requests_total", "{requests}", "Total number of git HTTP requests by type"),
snapshotServeTotal: metrics.NewMetric[metric.Int64Counter](meter, "cachew.git.snapshot_serves_total", "{serves}", "Snapshot serve events by source (cache, spool, cold_cache, generated) and repository"),
snapshotServeSize: metrics.NewHistogram(meter, "cachew.git.snapshot_serve_bytes", "By", "Size of served snapshots in bytes", metrics.ByteBuckets()),
snapshotServeDuration: metrics.NewHistogram(meter, "cachew.git.snapshot_serve_duration_seconds", "s", "Wall-clock duration of snapshot serves, from handler entry to last byte sent", metrics.LatencyBuckets()),
bundleServeTotal: metrics.NewMetric[metric.Int64Counter](meter, "cachew.git.bundle_serves_total", "{serves}", "Bundle serve events by source (cache, generated, up_to_date, miss_bad_base, miss) and repository"),
bundleServeSize: metrics.NewHistogram(meter, "cachew.git.bundle_serve_bytes", "By", "Size of served bundles in bytes", metrics.ByteBuckets()),
bundleServeDuration: metrics.NewHistogram(meter, "cachew.git.bundle_serve_duration_seconds", "s", "Wall-clock duration of bundle serves, including any on-demand generation", metrics.LatencyBuckets()),
ensureRefsTotal: metrics.NewMetric[metric.Int64Counter](meter, "cachew.git.ensure_refs_total", "{requests}", "EnsureRefs requests by fetched and status"),
ensureRefsDuration: metrics.NewHistogram(meter, "cachew.git.ensure_refs_duration_seconds", "s", "Duration of EnsureRefs requests, including any upstream fetch", metrics.FastLatencyBuckets()),
spoolWriterDuration: metrics.NewHistogram(meter, "cachew.git.spool_writer_duration_seconds", "s", "Time the snapshot spool writer spent producing the stream", metrics.LatencyBuckets()),
spoolFollowerWaitTotal: metrics.NewMetric[metric.Int64Counter](meter, "cachew.git.spool_follower_waits_total", "{waits}", "Snapshot spool follower events, by outcome (served, writer_failed)"),
spoolFollowerWait: metrics.NewHistogram(meter, "cachew.git.spool_follower_wait_seconds", "s", "Time a snapshot spool follower spent waiting for the writer to publish headers", metrics.FastLatencyBuckets()),
repackPackCount: metrics.NewHistogram(meter, "cachew.git.repack_pack_count", "{packs}", "Pack file count observed before and after repack, by stage (before, after)", metrics.SmallCountBuckets()),
snapshotServeBandwidth: metrics.NewHistogram(meter, "cachew.git.snapshot_serve_bandwidth_mbps", "MiBy/s", "Per-request snapshot serve throughput in MiB/s, by source and repository", metrics.BandwidthMbpsBuckets()),
lfsPhaseDuration: metrics.NewHistogram(meter, "cachew.git.lfs_phase_duration_seconds", "s", "Duration of an LFS-snapshot generation phase (discover, clone, fetch, archive_upload), by status and repository", metrics.LatencyBuckets()),
lfsPhaseBytes: metrics.NewHistogram(meter, "cachew.git.lfs_phase_bytes", "By", "Bytes processed in an LFS-snapshot generation phase, by phase and repository (e.g. .git/lfs size after fetch)", metrics.ByteBuckets()),
uploadPackCloneGateTotal: metrics.NewMetric[metric.Int64Counter](meter, "cachew.git.upload_pack_clone_gate_total", "{requests}", "Clone-shaped git-upload-pack admissions, rejections, and cancellations"),
uploadPackCloneQueueWait: metrics.NewHistogram(meter, "cachew.git.upload_pack_clone_queue_wait_seconds", "s", "Time spent waiting for a clone-shaped upload-pack slot", metrics.FastLatencyBuckets()),
}
}

Expand Down Expand Up @@ -183,3 +187,19 @@ func (m *gitMetrics) recordLFSPhaseBytes(ctx context.Context, repo, phase string
attribute.String("phase", phase),
))
}

// recordUploadPackCloneGate records the outcome of the clone-shaped
// upload-pack concurrency gate. Result is "admitted", "rejected", or "canceled".
func (m *gitMetrics) recordUploadPackCloneGate(ctx context.Context, result, repo string, waited time.Duration) {
if m == nil {
return
}
attrs := metric.WithAttributes(
attribute.String("result", result),
attribute.String("repository", repo),
)
m.uploadPackCloneGateTotal.Add(ctx, 1, attrs)
if waited > 0 {
m.uploadPackCloneQueueWait.Record(ctx, waited.Seconds(), attrs)
}
}
184 changes: 184 additions & 0 deletions internal/strategy/git/uploadpack_limit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
package git

import (
"context"
"net/http"
"strconv"
"sync"
"time"

"github.com/block/cachew/internal/logging"
)

type acquireResult int

const (
acquireOK acquireResult = iota
acquireOverloaded
acquireCanceled
)

func (r acquireResult) String() string {
switch r {
case acquireOK:
return "admitted"
case acquireOverloaded:
return "rejected"
case acquireCanceled:
return "canceled"
default:
return "unknown"
}
}

// uploadPackLimiter bounds concurrent clone-shaped git-upload-pack responses.
// Incremental fetches are never passed through this type.
type uploadPackLimiter struct {
global chan struct{} // nil if unlimited
perRepoLimit int
perRepo sync.Map // string -> chan struct{}
queueTimeout time.Duration
retryAfter time.Duration
}

func newUploadPackLimiter(cfg Config) *uploadPackLimiter {
l := &uploadPackLimiter{
perRepoLimit: cfg.UploadPackClonePerRepoConcurrency,
queueTimeout: cfg.UploadPackCloneQueueTimeout,
retryAfter: cfg.UploadPackCloneRetryAfter,
}
if l.retryAfter <= 0 {
l.retryAfter = 30 * time.Second
}
if cfg.UploadPackCloneConcurrency > 0 {
l.global = newTokenSem(cfg.UploadPackCloneConcurrency)
}
return l
}

func newTokenSem(n int) chan struct{} {
ch := make(chan struct{}, n)
for range n {
ch <- struct{}{}
}
return ch
}

func (l *uploadPackLimiter) enabled() bool {
return l != nil && (l.global != nil || l.perRepoLimit > 0)
}

func (l *uploadPackLimiter) repoSem(repo string) chan struct{} {
if v, ok := l.perRepo.Load(repo); ok {
return v.(chan struct{})
}
ch := newTokenSem(l.perRepoLimit)
actual, _ := l.perRepo.LoadOrStore(repo, ch)
return actual.(chan struct{})
}

func (l *uploadPackLimiter) acquire(ctx context.Context, repo string) (func(), acquireResult, time.Duration) {
release := func() {}
if !l.enabled() {
return release, acquireOK, 0
}
start := time.Now()
var deadline time.Time
if l.queueTimeout > 0 {
deadline = start.Add(l.queueTimeout)
}

if l.global != nil {
if result := waitToken(ctx, l.global, deadline); result != acquireOK {
return release, result, time.Since(start)
}
}

var repoCh chan struct{}
if l.perRepoLimit > 0 {
repoCh = l.repoSem(repo)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 M1 (major): the global token acquired at L92 is held while this waits for the per-repo slot, sharing the same deadline. A hot repo's waiters therefore pin global tokens: with 8/2/60s and 25 java clones, 6 java waiters occupy 6/8 global slots for 60s at a time while only 2 clones run, and go-square (2 free slots) is rejected. Repro (global=2, per-repo=1, timeout=2s; A running + A queued): B waited 1.9s and was admitted only when A's waiter timed out. Acquire per-repo first, then global — a per-repo waiter then blocks only its own repo, which needs a global slot anyway. TestUploadPackLimiterReleasesGlobalIfPerRepoRejects covers release-after-reject, not hold-while-waiting.

if result := waitToken(ctx, repoCh, deadline); result != acquireOK {
if l.global != nil {
l.global <- struct{}{}
}
return release, result, time.Since(start)
}
}

return func() {
if repoCh != nil {
repoCh <- struct{}{}
}
if l.global != nil {
l.global <- struct{}{}
}
}, acquireOK, time.Since(start)
}

func waitToken(ctx context.Context, ch chan struct{}, deadline time.Time) acquireResult {
if ctx.Err() != nil {
return acquireCanceled
}
if deadline.IsZero() {
select {
case <-ch:
return acquireOK
default:
return acquireOverloaded
}
}
remaining := time.Until(deadline)
if remaining <= 0 {
select {
case <-ch:
return acquireOK
default:
return acquireOverloaded
}
}
timer := time.NewTimer(remaining)
defer timer.Stop()
select {
case <-ch:
return acquireOK
case <-ctx.Done():
return acquireCanceled
case <-timer.C:
return acquireOverloaded
}
}

func (s *Strategy) gateCloneUploadPack(w http.ResponseWriter, r *http.Request, repo string) (func(), bool) {
release, result, waited := s.uploadPackLimiter.acquire(r.Context(), repo)
s.metrics.recordUploadPackCloneGate(r.Context(), result.String(), repo, waited)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 m1 (minor): this records cachew.git.upload_pack_clone_gate_total{result=admitted, repository=<upstream URL>} on every clone-shaped POST even when the limiter is disabled (enabled() false), so the "defaults are byte-for-byte unchanged" claim isn't quite true: every deployment gets a new per-repository series on an unbounded key space (any POST /git/<host>/<x>/git-upload-pack with no body creates one; the Datadog openmetrics check scrapes .*). Return early when !l.enabled() before touching metrics, and consider dropping/bucketing the repository attribute. Also IncrementClone at git.go:401 runs before the gate, so rejections and client retries inflate the clone histogram.

switch result {
case acquireOK:
if waited > 0 {
logging.FromContext(r.Context()).InfoContext(r.Context(),
"Admitted clone-shaped upload-pack after queue wait",
"upstream", repo, "waited", waited)
}
return release, true
case acquireCanceled:
return func() {}, false
case acquireOverloaded:
s.rejectCloneUploadPack(w, r, repo)
return func() {}, false
default:
s.rejectCloneUploadPack(w, r, repo)
return func() {}, false
}
}

func (s *Strategy) rejectCloneUploadPack(w http.ResponseWriter, r *http.Request, repo string) {
retryAfter := 30 * time.Second
if s.uploadPackLimiter != nil && s.uploadPackLimiter.retryAfter > 0 {
retryAfter = s.uploadPackLimiter.retryAfter
}
sec := max(int(retryAfter.Seconds()), 1)
logging.FromContext(r.Context()).WarnContext(r.Context(),
"Rejecting clone-shaped upload-pack due to concurrency limit",
"upstream", repo, "retry_after", retryAfter)
w.Header().Set("Retry-After", strconv.Itoa(sec))
http.Error(w, "too many concurrent git clones; retry later", http.StatusServiceUnavailable)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 M2 (major): the PR description says "Git retries 503; clients should honor Retry-After" — upstream git does not. post_rpcrun_slot treats anything but HTTP_OK/HTTP_REAUTH as fatal (remote-curl.c:859-872, 1066-1076error: RPC failed; HTTP 503 / fatal: expected 'packfile'). Retry-After is only honoured for 429, and only in http_request_recoverable (GET path, http.c:2405-2440, git ≥ 2.50). So the client (EvalHub) must implement retry itself; the description should say so. Separately, the chart's DestinationRule has outlierDetection { consecutive5xxErrors: 5, interval: 30s } and Envoy counts only 5xx for HTTP upstreams: ≥5 consecutive rejections from one pod inside 30s eject that pod from the caller's Envoy for 30s and push the burst onto the remaining pods. A 429 would not count toward ejection. Worth choosing deliberately and documenting.

}
Loading