Skip to content

Commit 760998a

Browse files
committed
fix(k8s): resume Job watch after HTTP/2 stream drops
GKE's apiserver LB emits watch.Error "http2: client connection lost" instead of closing the channel. That is a dead stream, not a failed Job; treat it like 410 Gone and re-establish the watch so long validators are not marked failed while still running. Signed-off-by: Rohit Rajani <rorajani@nvidia.com>
1 parent 6b236b0 commit 760998a

3 files changed

Lines changed: 144 additions & 9 deletions

File tree

pkg/k8s/pod/job.go

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ package pod
1717
import (
1818
"context"
1919
"log/slog"
20+
"strings"
2021
"time"
2122

2223
"github.com/NVIDIA/aicr/pkg/defaults"
@@ -49,24 +50,48 @@ func resumeContext(namespace, name string) map[string]any {
4950
}
5051

5152
// isRetryableWatchError reports whether a watch.Error event is a routine,
52-
// resumable signal (HTTP 410 Gone / ResourceExpired — the apiserver compacted
53-
// past the watch's ResourceVersion) rather than a fatal stream error. Callers
54-
// treat a retryable error like a channel close and re-establish the watch;
55-
// anything else aborts the wait.
53+
// resumable signal rather than a fatal stream error. Callers treat a retryable
54+
// error like a channel close and re-establish the watch; anything else aborts
55+
// the wait.
5656
//
57-
// Against a real apiserver this is the *common* shape of a stale-ResourceVersion
58-
// rejection: the Watch call itself succeeds and the 410 arrives as the stream's
59-
// first ERROR event, not as a synchronous call error.
57+
// Retryable shapes:
58+
// - HTTP 410 Gone / ResourceExpired — the apiserver compacted past the
59+
// watch's ResourceVersion. Against a real apiserver this is the *common*
60+
// shape of a stale-RV rejection: the Watch call itself succeeds and the
61+
// 410 arrives as the stream's first ERROR event, not as a synchronous
62+
// call error.
63+
// - Timeout / server-timeout / 429 / 503 — the apiserver or an LB asked
64+
// the client to retry; the Job did not fail.
65+
// - client-go StreamWatcher decode failures, including
66+
// "http2: client connection lost". GKE (and any HTTP/2 kube-apiserver
67+
// fronted by a load balancer) drops idle watch streams this way. The
68+
// ERROR event is the same class as a channel close: the stream died;
69+
// the Job did not. UAT run 32765635777 failed inference-perf on this
70+
// exact Status while the AIPerf Job was still running.
71+
//
72+
// Generic InternalError (RBAC bugs, etcd faults, "boom") stays fatal so a
73+
// real defect is not retried until the deadline.
6074
func isRetryableWatchError(event watch.Event) bool {
6175
if event.Type != watch.Error {
6276
return false
6377
}
6478
err := apierrors.FromObject(event.Object)
65-
return apierrors.IsResourceExpired(err) || apierrors.IsGone(err)
79+
if err == nil {
80+
return false
81+
}
82+
if apierrors.IsResourceExpired(err) || apierrors.IsGone(err) ||
83+
apierrors.IsTimeout(err) || apierrors.IsServerTimeout(err) ||
84+
apierrors.IsTooManyRequests(err) || apierrors.IsServiceUnavailable(err) {
85+
return true
86+
}
87+
msg := strings.ToLower(err.Error())
88+
return strings.Contains(msg, "http2: client connection lost") ||
89+
strings.Contains(msg, "unable to decode an event from the watch stream")
6690
}
6791

6892
// resumeJobWatch reconnects a Job watch that ended — the channel closed or the
69-
// apiserver emitted a retryable 410 — before the Job reached a terminal state.
93+
// apiserver emitted a retryable watch.Error (410, HTTP/2 drop) — before the
94+
// Job reached a terminal state.
7095
//
7196
// It resyncs via a field-selected List rather than a Get, which is load-bearing:
7297
// - The List's collection ResourceVersion (metadata.resourceVersion) is the

pkg/k8s/pod/job_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,49 @@ func TestWaitForJobCompletion_RetryableWatchErrorResumes(t *testing.T) {
456456
}
457457
}
458458

459+
// TestWaitForJobCompletion_HTTP2DropWatchErrorResumes is the UAT flake from
460+
// run 32765635777: client-go StreamWatcher emits InternalError
461+
// "unable to decode an event from the watch stream: http2: client connection
462+
// lost" when GKE's apiserver LB drops the watch. That is a dead stream, not
463+
// a failed Job — resume and keep waiting.
464+
func TestWaitForJobCompletion_HTTP2DropWatchErrorResumes(t *testing.T) {
465+
t.Parallel()
466+
467+
client := fake.NewSimpleClientset(&batchv1.Job{ //nolint:staticcheck
468+
ObjectMeta: metav1.ObjectMeta{Name: "j", Namespace: "default", ResourceVersion: "1"},
469+
})
470+
471+
var watches atomic.Int32
472+
first := watch.NewFake()
473+
second := watch.NewFake()
474+
client.PrependWatchReactor("jobs", func(_ k8stesting.Action) (bool, watch.Interface, error) {
475+
switch watches.Add(1) {
476+
case 1:
477+
return true, first, nil
478+
case 2:
479+
return true, second, nil
480+
default:
481+
return true, watch.NewFake(), nil
482+
}
483+
})
484+
485+
go func() {
486+
time.Sleep(10 * time.Millisecond)
487+
lost := apierrors.NewInternalError(stderrors.New(
488+
"unable to decode an event from the watch stream: http2: client connection lost"))
489+
first.Error(&lost.ErrStatus)
490+
time.Sleep(30 * time.Millisecond)
491+
second.Modify(terminalJobRV("2", batchv1.JobComplete))
492+
}()
493+
494+
if err := pod.WaitForJobCompletion(context.Background(), client, "default", "j", 2*time.Second); err != nil {
495+
t.Fatalf("expected nil after http2-drop watch error resume, got: %v", err)
496+
}
497+
if got := watches.Load(); got < 2 {
498+
t.Errorf("expected at least 2 watch attempts (resume after http2 drop), got %d", got)
499+
}
500+
}
501+
459502
// TestWaitForJobCompletion_FatalWatchErrorReturns confirms a non-retryable
460503
// watch.Error still aborts the wait with ErrCodeInternal rather than resuming.
461504
func TestWaitForJobCompletion_FatalWatchErrorReturns(t *testing.T) {

pkg/k8s/pod/wait_internal_test.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,3 +362,70 @@ func TestWatchClosedContext(t *testing.T) {
362362
}
363363
})
364364
}
365+
366+
func TestIsRetryableWatchError(t *testing.T) {
367+
t.Parallel()
368+
369+
errorEvent := func(obj runtime.Object) watch.Event {
370+
return watch.Event{Type: watch.Error, Object: obj}
371+
}
372+
statusOf := func(err error) runtime.Object {
373+
var statusErr *apierrors.StatusError
374+
if !stderrors.As(err, &statusErr) {
375+
t.Fatalf("expected *apierrors.StatusError, got %T", err)
376+
}
377+
return &statusErr.ErrStatus
378+
}
379+
380+
tests := []struct {
381+
name string
382+
event watch.Event
383+
want bool
384+
}{
385+
{name: "added event is not retryable", event: watch.Event{Type: watch.Added}, want: false},
386+
{
387+
name: "410 ResourceExpired is retryable",
388+
event: errorEvent(statusOf(apierrors.NewResourceExpired("compacted"))),
389+
want: true,
390+
},
391+
{
392+
name: "410 Gone is retryable",
393+
event: errorEvent(statusOf(apierrors.NewGone("gone"))),
394+
want: true,
395+
},
396+
{
397+
name: "503 ServiceUnavailable is retryable",
398+
event: errorEvent(statusOf(apierrors.NewServiceUnavailable("apiserver down"))),
399+
want: true,
400+
},
401+
{
402+
name: "timeout is retryable",
403+
event: errorEvent(statusOf(apierrors.NewTimeoutError("timed out", 1))),
404+
want: true,
405+
},
406+
{
407+
name: "http2 client connection lost is retryable",
408+
event: errorEvent(statusOf(apierrors.NewInternalError(
409+
stderrors.New("unable to decode an event from the watch stream: http2: client connection lost")))),
410+
want: true,
411+
},
412+
{
413+
name: "generic InternalError stays fatal",
414+
event: errorEvent(statusOf(apierrors.NewInternalError(stderrors.New("boom")))),
415+
want: false,
416+
},
417+
{
418+
name: "Forbidden stays fatal",
419+
event: errorEvent(statusOf(apierrors.NewForbidden(batchv1.Resource("jobs"), "j", stderrors.New("denied")))),
420+
want: false,
421+
},
422+
}
423+
for _, tt := range tests {
424+
t.Run(tt.name, func(t *testing.T) {
425+
t.Parallel()
426+
if got := isRetryableWatchError(tt.event); got != tt.want {
427+
t.Errorf("isRetryableWatchError() = %v, want %v", got, tt.want)
428+
}
429+
})
430+
}
431+
}

0 commit comments

Comments
 (0)