Skip to content

Commit 59f9a9f

Browse files
committed
Fix aggregated API server readiness race causing sporadic 503s in envtest suites
Signed-off-by: Andreas Fritzler <andreas.fritzler@sap.com>
1 parent a0e22c5 commit 59f9a9f

1 file changed

Lines changed: 119 additions & 0 deletions

File tree

utils/envtest/envtest.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"context"
88
"errors"
99
"fmt"
10+
"net/url"
1011
"os"
1112
"path/filepath"
1213
"reflect"
@@ -18,6 +19,7 @@ import (
1819
"github.com/ironcore-dev/ironcore/utils/envtest/internal/testing/addr"
1920
"github.com/ironcore-dev/ironcore/utils/envtest/internal/testing/certs"
2021
corev1 "k8s.io/api/core/v1"
22+
apierrors "k8s.io/apimachinery/pkg/api/errors"
2123
"k8s.io/apimachinery/pkg/api/meta"
2224
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2325
"k8s.io/apimachinery/pkg/runtime"
@@ -658,9 +660,126 @@ func WaitUntilAPIServicesReady(ctx context.Context, ext *EnvironmentExtensions,
658660
return fmt.Errorf("error waiting for group versions to be discoverable: %w", err)
659661
}
660662

663+
if err := waitUntilAPIGroupsServe(ctx, c, scheme, groupVersions...); err != nil {
664+
return fmt.Errorf("error waiting for aggregated api server to serve requests: %w", err)
665+
}
666+
667+
return nil
668+
}
669+
670+
// serveReadinessPollInterval is the interval at which the aggregated API server
671+
// is probed for serving real resource round-trips.
672+
const serveReadinessPollInterval = 100 * time.Millisecond
673+
674+
// serveReadinessStableDuration is how long the aggregated API server must
675+
// continuously serve real resource round-trips before startup is considered
676+
// complete.
677+
//
678+
// The APIService Available condition and group discovery can report ready
679+
// while the aggregated server still returns transient 503 ServiceUnavailable
680+
// responses for a short window afterwards. Requiring sustained success absorbs
681+
// that window so tests observe a genuinely serving API server.
682+
const serveReadinessStableDuration = 500 * time.Millisecond
683+
684+
// waitUntilAPIGroupsServe waits until the aggregated API server actually serves
685+
// resource round-trips for each of the given group versions by issuing a real
686+
// List per group. Unlike the APIService Available condition and discovery
687+
// (which are reported by / served from the kube-apiserver), a List is proxied
688+
// to the aggregated server, so it observes the transient 503 startup window and
689+
// absorbs it before the test suite proceeds.
690+
func waitUntilAPIGroupsServe(ctx context.Context, c client.Client, scheme *runtime.Scheme, gvs ...schema.GroupVersion) error {
691+
targets, err := newListRoundTripTargets(scheme, gvs)
692+
if err != nil {
693+
return fmt.Errorf("error determining list round-trip targets: %w", err)
694+
}
695+
if len(targets) == 0 {
696+
return nil
697+
}
698+
699+
requiredSuccesses := int(serveReadinessStableDuration / serveReadinessPollInterval)
700+
if requiredSuccesses < 1 {
701+
requiredSuccesses = 1
702+
}
703+
704+
successes := 0
705+
var lastErr error
706+
if err := wait.PollUntilContextCancel(ctx, serveReadinessPollInterval, true, func(ctx context.Context) (bool, error) {
707+
for _, target := range targets {
708+
if err := c.List(ctx, target.list); err != nil {
709+
if !isTransientServeError(err) {
710+
return false, fmt.Errorf("unexpected error listing %s: %w", target.gv, err)
711+
}
712+
lastErr = err
713+
successes = 0
714+
return false, nil
715+
}
716+
}
717+
successes++
718+
return successes >= requiredSuccesses, nil
719+
}); err != nil {
720+
if lastErr != nil {
721+
return fmt.Errorf("%w (last error: %v)", err, lastErr)
722+
}
723+
return err
724+
}
661725
return nil
662726
}
663727

728+
// listRoundTripTarget pairs a group version with a reusable list object used to
729+
// issue a real List round-trip against the aggregated API server.
730+
type listRoundTripTarget struct {
731+
gv schema.GroupVersion
732+
list client.ObjectList
733+
}
734+
735+
func newListRoundTripTargets(scheme *runtime.Scheme, gvs []schema.GroupVersion) ([]listRoundTripTarget, error) {
736+
var targets []listRoundTripTarget
737+
for _, gv := range gvs {
738+
list, ok := newListForGroupVersion(scheme, gv)
739+
if !ok {
740+
continue
741+
}
742+
targets = append(targets, listRoundTripTarget{gv: gv, list: list})
743+
}
744+
return targets, nil
745+
}
746+
747+
// newListForGroupVersion returns a fresh list object for the first list kind
748+
// registered for the given group version (e.g. *VolumeList), constructed via the
749+
// scheme rather than reflect so the scheme stays the single source of truth for
750+
// type instantiation.
751+
func newListForGroupVersion(scheme *runtime.Scheme, gv schema.GroupVersion) (client.ObjectList, bool) {
752+
for kind := range scheme.KnownTypes(gv) {
753+
obj, err := scheme.New(gv.WithKind(kind))
754+
if err != nil {
755+
continue
756+
}
757+
if list, ok := obj.(client.ObjectList); ok {
758+
return list, true
759+
}
760+
}
761+
return nil, false
762+
}
763+
764+
// isTransientServeError reports whether an error returned while probing the
765+
// aggregated API server is a transient startup condition worth retrying.
766+
func isTransientServeError(err error) bool {
767+
if err == nil {
768+
return false
769+
}
770+
if apierrors.IsServiceUnavailable(err) ||
771+
apierrors.IsServerTimeout(err) ||
772+
apierrors.IsTimeout(err) ||
773+
apierrors.IsTooManyRequests(err) ||
774+
apierrors.IsInternalError(err) {
775+
return true
776+
}
777+
// Transport-level errors (connection refused, EOF, reset) while the
778+
// aggregated server is still coming up.
779+
var urlErr *url.Error
780+
return errors.As(err, &urlErr)
781+
}
782+
664783
func WaitUntilAPIServicesReadyWithTimeout(timeout time.Duration, ext *EnvironmentExtensions, cfg *rest.Config, c client.Client, scheme *runtime.Scheme) error {
665784
ctx, cancel := context.WithTimeout(context.Background(), timeout)
666785
defer cancel()

0 commit comments

Comments
 (0)