Repository: envoyproxy/gateway

Issue:
Delayed endpoint updates for specific HTTPRoute causing 503 errors
## Description

We're experiencing a critical issue with Envoy Gateway where endpoint updates are delayed by 5-10 minutes for a specific HTTPRoute (Keycloak), resulting in 503 errors during deployments. This issue is reproducible only with this particular HTTPRoute, while other HTTPRoutes in our cluster work correctly.

## Environment

- Kubernetes Version: GKE v1.32 with dataplane v2
- Envoy Gateway Version: v1.5.4
- Cluster Setup:
   - 2 Envoy Gateway instances
   - 2 proxy replicas per gateway
   - 1  GKE native gateway (same namespace)
- Routing Mode: Endpoints (issue doesn't occur with Service mode, but that's not a viable solution for us)

## Observed Behavior

During deployment rollouts of the Keycloak application, Envoy proxies receive endpoint updates with significant delays (5-10 minutes), causing:
- Incorrect proxy state
- 503 errors for incoming requests
- Traffic being routed to terminated pods

We monitored this using:
```
viddy 'egctl config envoy-proxy endpoint -n gateways -l gateway.envoyproxy.io/owning-gateway-name=my-public-envoy | jq -S "
  .gateways |= with_entries(
    .value.dynamicEndpointConfigs |= sort_by(.endpointConfig.clusterName) |
    .value.staticEndpointConfigs |= sort_by(.endpointConfig.clusterName)
  )
"'
``` 

## Expected Behavior

Endpoint updates should propagate to Envoy proxies within seconds (similar to other HTTPRoutes in the cluster), ensuring zero-downtime deployments.

## Manifests

```yaml
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: keycloak
  namespace: keycloak
spec:
  hostnames:
    - keycloak.my.domain
  parentRefs:
    - group: gateway.networking.k8s.io
      kind: Gateway
      name: my-public-envoy
      namespace: gateways
      sectionName: https
  rules:
    - backendRefs:
        - group: ''
          kind: Service
          name: keycloak
          port: 8080
          weight: 1
      matches:
        - path:
            type: PathPrefix
            value: /realms/
        - path:
            type: PathPrefix
            value: /resources/
        - path:
            type: Exact
            value: /robots.txt
status:
  parents:
    - conditions:
        - lastTransitionTime: '2025-10-27T16:23:15Z'
          message: Route is accepted
          observedGeneration: 3
          reason: Accepted
          status: 'True'
          type: Accepted
        - lastTransitionTime: '2025-10-27T16:23:15Z'
          message: Resolved all the Object references for the Route
          observedGeneration: 3
          reason: ResolvedRefs
          status: 'True'
          type: ResolvedRefs
      controllerName: gateway.envoyproxy.io/gatewayclass-controller
      parentRef:
        group: gateway.networking.k8s.io
        kind: Gateway
        name: my-public-envoy
        namespace: gateways
        sectionName: https
```
```yaml
apiVersion: v1
kind: Service
metadata:
  name: keycloak
  namespace: keycloak
spec:
  clusterIP: 10.3.12.238
  clusterIPs:
    - 10.3.12.238
  internalTrafficPolicy: Cluster
  ipFamilies:
    - IPv4
  ipFamilyPolicy: SingleStack
  ports:
    - name: http
      port: 8080
      protocol: TCP
      targetPort: http
  selector:
    app.kubernetes.io/instance: keycloak
    app.kubernetes.io/name: keycloak
  sessionAffinity: None
  type: ClusterIP
status:
  loadBalancer: {}
```
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    app.kubernetes.io/instance: keycloak
    app.kubernetes.io/managed-by: Helm
    app.kubernetes.io/name: keycloak
    app.kubernetes.io/version: 26.0.5
    helm.sh/chart: keycloak-0.1.0
  name: keycloak
  namespace: keycloak
spec:
  progressDeadlineSeconds: 600
  replicas: 1
  revisionHistoryLimit: 10
  selector:
    matchLabels:
      app.kubernetes.io/instance: keycloak
      app.kubernetes.io/name: keycloak
  strategy:
    rollingUpdate:
      maxSurge: 25%
      maxUnavailable: 25%
    type: RollingUpdate
  template:
    metadata:
      labels:
        app.kubernetes.io/instance: keycloak
        app.kubernetes.io/managed-by: Helm
        app.kubernetes.io/name: keycloak
        app.kubernetes.io/version: 26.0.5
        helm.sh/chart: keycloak-0.1.0
    spec:
      containers:
        - args:
            - start
            - '--verbose'
            - '--log-console-output=json'
            - '--log-level=INFO,org.keycloak:DEBUG'
            - '--features=user-event-metrics,client-secret-rotation'
          env:
            - name: STAKATER_KEYCLOAK_CONFIGMAP
              value: 4e6c49a1c79cdff645dce8afc232d95bc93155b4
            - name: KC_DB_USERNAME
              valueFrom:
                secretKeyRef:
                  key: LOGIN
                  name: keycloak-db
            - name: KC_DB_PASSWORD
              valueFrom:
                secretKeyRef:
                  key: PASSWORD
                  name: keycloak-db
            - name: KC_DB_URL_HOST
              valueFrom:
                secretKeyRef:
                  key: HOST
                  name: keycloak-db
            - name: KC_DB_URL_PORT
              valueFrom:
                secretKeyRef:
                  key: PORT
                  name: keycloak-db
          envFrom:
            - secretRef:
                name: keycloak-env
            - configMapRef:
                name: keycloak
          image: europe-docker.pkg.dev/my-company/docker/keycloak:main-2ac8399
          imagePullPolicy: IfNotPresent
          livenessProbe:
            failureThreshold: 3
            httpGet:
              path: /health/live
              port: management
              scheme: HTTPS
            periodSeconds: 10
            successThreshold: 1
            timeoutSeconds: 1
          name: keycloak
          ports:
            - containerPort: 8080
              name: http
              protocol: TCP
            - containerPort: 9000
              name: management
              protocol: TCP
          readinessProbe:
            failureThreshold: 3
            httpGet:
              path: /health/ready
              port: management
              scheme: HTTPS
            periodSeconds: 10
            successThreshold: 1
            timeoutSeconds: 1
          resources:
            limits:
              memory: 3Gi
            requests:
              cpu: 100m
              memory: 1Gi
          securityContext: {}
          terminationMessagePath: /dev/termination-log
          terminationMessagePolicy: File
          volumeMounts:
            - mountPath: /certs/public
              name: public-tls
              readOnly: true
            - mountPath: /certs/private
              name: private-tls
              readOnly: true
      dnsPolicy: ClusterFirst
      restartPolicy: Always
      schedulerName: default-scheduler
      securityContext: {}
      serviceAccount: keycloak
      serviceAccountName: keycloak
      terminationGracePeriodSeconds: 30
      volumes:
        - name: public-tls
          secret:
            defaultMode: 420
            secretName: keycloak-public-tls
        - name: private-tls
          secret:
            defaultMode: 420
            secretName: keycloak-private-tls
status:
  availableReplicas: 1
  conditions:
    - lastTransitionTime: '2025-10-22T15:47:45Z'
      lastUpdateTime: '2025-10-22T15:47:45Z'
      message: Deployment has minimum availability.
      reason: MinimumReplicasAvailable
      status: 'True'
      type: Available
    - lastTransitionTime: '2025-09-24T19:04:05Z'
      lastUpdateTime: '2025-10-28T10:07:22Z'
      message: ReplicaSet "keycloak-7f64f656bf" has successfully progressed.
      reason: NewReplicaSetAvailable
      status: 'True'
      type: Progressing
  observedGeneration: 26
  readyReplicas: 1
  replicas: 1
  updatedReplicas: 1
```
 
Any guidance on debugging or resolving this issue would be greatly appreciated!

Hints from issue comments:
@arkodg: do you have any Policy that is being applied specifically for this route ?@duizabojul: No, there is only this HttpRoute.@duizabojul: I think this is the same issue as #7366, but this one is far better documented 😄 We can probably close this one.@coro: Sorry @duizabojul, didn't see this when raising mine! It does look to be the same symptoms as what we're experiencing in the other issue. Interesting that this is happening for you on a route with only 1 replica - we thought it was more likely to impact many-replica routes but this would imply otherwise!@duizabojul: Delay happens with only one replica indeed, but the more replicas you add, longer the delay  in average.@zhaohuabing: Since this happens during keycloak deployment rolls out. I suspect this may be related to the retries in SecurityPolicy when attempting to fetch the OIDC config from the issuer's `.well-known/openid-configuration` endpoint. @duizabojul could you check if you have SecurityPolicies where the keycloak is used for OIDC and share the config?

cc @coro @zhaohuabing: This should be fixed by #7328 and #7394.@duizabojul: I confirm this keycloak deployment is used in SecurityPolicies. It explains this only happens on the keycloak route. Thanks a lot for debugging !!  

Here is a config example
```yaml
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: SecurityPolicy
metadata:
  name: argo-workflows
  namespace: argo-workflows
spec:
  oidc:
    clientID: argo-workflows-central
    clientSecret:
      group: ""
      kind: Secret
      name: oidc-client
    provider:
      issuer: https://keycloak.acme.io/realms/acme
    redirectURL: https://argo-workflows.acme.io/iap/callback
    refreshToken: true
  targetRefs:
  - group: gateway.networking.k8s.io
    kind: HTTPRoute
    name: argo-workflows
status:
  ancestors:
  - ancestorRef:
      group: gateway.networking.k8s.io
      kind: Gateway
      name: acme-public-envoy
      namespace: gateways
    conditions:
    - lastTransitionTime: "2025-10-27T00:27:54Z"
      message: Policy has been accepted.
      observedGeneration: 3
      reason: Accepted
      status: "True"
      type: Accepted
    controllerName: gateway.envoyproxy.io/gatewayclass-controller
```

Solution patch:
diff --git a/internal/gatewayapi/contexts.go b/internal/gatewayapi/contexts.go
index 0dd197cce..8ee05a241 100644
--- a/internal/gatewayapi/contexts.go
+++ b/internal/gatewayapi/contexts.go
@@ -44,8 +44,6 @@ func (g *GatewayContext) ResetListeners(resources *resource.Resources, epMap map
 			listenerStatusIdx: i,
 		}
 	}
-
-	g.attachEnvoyProxy(resources, epMap)
 }
 
 func (g *GatewayContext) attachEnvoyProxy(resources *resource.Resources, epMap map[types.NamespacedName]*egv1a1.EnvoyProxy) {
diff --git a/internal/gatewayapi/securitypolicy.go b/internal/gatewayapi/securitypolicy.go
index 61ed8b64a..4dca669f8 100644
--- a/internal/gatewayapi/securitypolicy.go
+++ b/internal/gatewayapi/securitypolicy.go
@@ -43,6 +43,7 @@ const (
 	defaultForwardAccessToken    = false
 	defaultRefreshToken          = true
 	defaultPassThroughAuthHeader = false
+	defaultOIDCHTTPTimeout       = 5 * time.Second
 
 	// nolint: gosec
 	oidcHMACSecretName = "envoy-oidc-hmac"
@@ -55,6 +56,10 @@ func (t *Translator) ProcessSecurityPolicies(securityPolicies []*egv1a1.Security
 	resources *resource.Resources,
 	xdsIR resource.XdsIRMap,
 ) []*egv1a1.SecurityPolicy {
+	// Cache is only reused during one translation across multiple routes and gateways.
+	// The failed fetches will be retried in the next translation when the provider resources are reconciled again.
+	t.oidcDiscoveryCache = newOIDCDiscoveryCache()
+
 	// SecurityPolicies are already sorted by the provider layer
 
 	// First build a map out of the routes and gateways for faster lookup since users might have thousands of routes or more.
@@ -1419,7 +1424,7 @@ func (t *Translator) buildOIDCProvider(policy *egv1a1.SecurityPolicy, resources
 	// EG assumes that the issuer url uses the same protocol and CA as the token endpoint.
 	// If we need to support different protocols or CAs, we need to add more fields to the OIDCProvider CRD.
 	if provider.TokenEndpoint == nil || provider.AuthorizationEndpoint == nil {
-		discoveredConfig, err := fetchEndpointsFromIssuer(provider.Issuer, providerTLS)
+		discoveredConfig, err := t.fetchEndpointsFromIssuer(provider.Issuer, providerTLS)
 		if err != nil {
 			return nil, err
 		}
@@ -1507,7 +1512,25 @@ func (o *OpenIDConfig) validate() error {
 	return nil
 }
 
-func fetchEndpointsFromIssuer(issuerURL string, providerTLS *ir.TLSUpstreamConfig) (*OpenIDConfig, error) {
+func (t *Translator) fetchEndpointsFromIssuer(issuerURL string, providerTLS *ir.TLSUpstreamConfig) (*OpenIDConfig, error) {
+	if config, cachedErr, ok := t.oidcDiscoveryCache.Get(issuerURL); ok {
+		if cachedErr != nil {
+			return nil, cachedErr
+		}
+		return config, nil
+	}
+
+	config, err := discoverEndpointsFromIssuer(issuerURL, providerTLS)
+	if err != nil {
+		t.oidcDiscoveryCache.Set(issuerURL, nil, err)
+		return nil, err
+	}
+
+	t.oidcDiscoveryCache.Set(issuerURL, config, nil)
+	return config, nil
+}
+
+func discoverEndpointsFromIssuer(issuerURL string, providerTLS *ir.TLSUpstreamConfig) (*OpenIDConfig, error) {
 	var (
 		tlsConfig *tls.Config
 		err       error
@@ -1519,7 +1542,7 @@ func fetchEndpointsFromIssuer(issuerURL string, providerTLS *ir.TLSUpstreamConfi
 		}
 	}
 
-	client := &http.Client{}
+	client := &http.Client{Timeout: defaultOIDCHTTPTimeout}
 	if tlsConfig != nil {
 		client.Transport = &http.Transport{
 			TLSClientConfig: tlsConfig,
@@ -1564,6 +1587,47 @@ func fetchEndpointsFromIssuer(issuerURL string, providerTLS *ir.TLSUpstreamConfi
 	return &config, nil
 }
 
+// oidcDiscoveryCache is a cache for auto-discovered OIDC configurations from the issuer's well-known URL.
+// The cache is only used within the current translation, so no need to lock it or expire entries.
+type oidcDiscoveryCache struct {
+	entries map[string]cachedOIDCEntry
+}
+
+type cachedOIDCEntry struct {
+	config *OpenIDConfig
+	err    error
+}
+
+func newOIDCDiscoveryCache() *oidcDiscoveryCache {
+	return &oidcDiscoveryCache{
+		entries: make(map[string]cachedOIDCEntry),
+	}
+}
+
+func (c *oidcDiscoveryCache) Get(issuer string) (*OpenIDConfig, error, bool) {
+	if c == nil {
+		return nil, nil, false
+	}
+
+	entry, ok := c.entries[issuer]
+	if !ok {
+		return nil, nil, false
+	}
+
+	return entry.config, entry.err, true
+}
+
+func (c *oidcDiscoveryCache) Set(issuer string, cfg *OpenIDConfig, err error) {
+	if c == nil {
+		return
+	}
+
+	c.entries[issuer] = cachedOIDCEntry{
+		config: cfg,
+		err:    err,
+	}
+}
+
 func retryable(code int) bool {
 	return code >= 500 &&
 		(code != http.StatusNotImplemented &&
diff --git a/internal/gatewayapi/translator.go b/internal/gatewayapi/translator.go
index 1a38a6ff2..5f24eee3e 100644
--- a/internal/gatewayapi/translator.go
+++ b/internal/gatewayapi/translator.go
@@ -110,6 +110,9 @@ type Translator struct {
 	// and reuses the specified value.
 	ListenerPortShiftDisabled bool
 
+	// oidcDiscoveryCache is the cache for OIDC configurations discovered from issuer's well-known URL.
+	oidcDiscoveryCache *oidcDiscoveryCache
+
 	// Logger is the logger used by the translator.
 	Logger logging.Logger
 }
@@ -386,6 +389,7 @@ func (t *Translator) GetRelevantGateways(resources *resource.Resources) (
 		gCtx := &GatewayContext{
 			Gateway: gateway,
 		}
+		gCtx.attachEnvoyProxy(resources, envoyproxyMap)
 
 		// Gateways that are not accepted by the controller because they reference an invalid EnvoyProxy.
 		if status.GatewayNotAccepted(gCtx.Gateway) {
@@ -394,7 +398,6 @@ func (t *Translator) GetRelevantGateways(resources *resource.Resources) (
 			continue
 		}
 
-		gCtx.ResetListeners(resources, envoyproxyMap)
 		if ep := gCtx.envoyProxy; ep != nil {
 			key := utils.NamespacedName(ep)
 			if err, exits := envoyproxyValidationErrorMap[key]; exits {
@@ -406,6 +409,8 @@ func (t *Translator) GetRelevantGateways(resources *resource.Resources) (
 			}
 		}
 
+		// we cannot do this early, otherwise there's an error when updating status.
+		gCtx.ResetListeners(resources, envoyproxyMap)
 		acceptedGateways = append(acceptedGateways, gCtx)
 	}
 	return
diff --git a/internal/infrastructure/runner/runner.go b/internal/infrastructure/runner/runner.go
index 1c5d1f1e6..07a9de815 100644
--- a/internal/infrastructure/runner/runner.go
+++ b/internal/infrastructure/runner/runner.go
@@ -106,7 +106,7 @@ func (r *Runner) updateProxyInfraFromSubscription(ctx context.Context, sub <-cha
 	// Subscribe to resources
 	message.HandleSubscription(message.Metadata{Runner: r.Name(), Message: message.InfraIRMessageName}, sub,
 		func(update message.Update[string, *ir.Infra], errChan chan error) {
-			r.Logger.Info("received an update")
+			r.Logger.Info("received an update", "key", update.Key, "delete", update.Delete)
 			val := update.Value
 
 			if update.Delete {
diff --git a/internal/provider/kubernetes/controller.go b/internal/provider/kubernetes/controller.go
index b471261f1..fac9d8d8b 100644
--- a/internal/provider/kubernetes/controller.go
+++ b/internal/provider/kubernetes/controller.go
@@ -1605,11 +1605,7 @@ func (r *gatewayAPIReconciler) processServiceClusterForGatewayClass(ep *egv1a1.E
 		}
 	}
 
-	resourceMap.insertBackendRef(gwapiv1.BackendObjectReference{
-		Kind:      ptr.To(gwapiv1.Kind("Service")),
-		Namespace: gatewayapi.NamespacePtr(proxySvcNamespace),
-		Name:      gwapiv1.ObjectName(proxySvcName),
-	})
+	r.insertProxyServiceIfExists(proxySvcName, proxySvcNamespace, resourceMap)
 }
 
 // Called on a Gateway when merged gateways mode is not enabled for its parent GatewayClass.
@@ -1634,10 +1630,24 @@ func (r *gatewayAPIReconciler) processServiceClusterForGateway(ep *egv1a1.EnvoyP
 		}
 	}
 
+	r.insertProxyServiceIfExists(proxySvcName, proxySvcNamespace, resourceMap)
+}
+
+func (r *gatewayAPIReconciler) insertProxyServiceIfExists(name, namespace string, resourceMap *resourceMappings) {
+	svcNN := types.NamespacedName{Name: name, Namespace: namespace}
+	svc := new(corev1.Service)
+	err := r.client.Get(context.Background(), svcNN, svc)
+	// Only insert if service exists
+	if err != nil {
+		if !kerrors.IsNotFound(err) {
+			r.log.Error(err, "failed to get proxy service", "namespace", namespace, "name", name)
+		}
+		return
+	}
 	resourceMap.insertBackendRef(gwapiv1.BackendObjectReference{
-		Kind:      ptr.To(gwapiv1.Kind("Service")),
-		Namespace: gatewayapi.NamespacePtr(proxySvcNamespace),
-		Name:      gwapiv1.ObjectName(proxySvcName),
+		Kind:      ptr.To(gwapiv1.Kind(resource.KindService)),
+		Namespace: gatewayapi.NamespacePtr(svc.Namespace),
+		Name:      gwapiv1.ObjectName(svc.Name),
 	})
 }
 



Test patch:
diff --git a/internal/gatewayapi/securitypolicy_test.go b/internal/gatewayapi/securitypolicy_test.go
index 62fad681b..16067ef8a 100644
--- a/internal/gatewayapi/securitypolicy_test.go
+++ b/internal/gatewayapi/securitypolicy_test.go
@@ -6,7 +6,11 @@
 package gatewayapi
 
 import (
+	"fmt"
+	"net/http"
+	"net/http/httptest"
 	"regexp"
+	"sync/atomic"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
@@ -780,6 +784,79 @@ func TestValidateCIDRs_ErrorOnBadCIDR(t *testing.T) {
 	}
 }
 
+func TestTranslatorFetchEndpointsFromIssuerCache(t *testing.T) {
+	var (
+		callCount atomic.Int32
+		server    *httptest.Server
+	)
+
+	server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.URL.Path != "/.well-known/openid-configuration" {
+			http.NotFound(w, r)
+			return
+		}
+
+		callCount.Add(1)
+		w.Header().Set("Content-Type", "application/json")
+		_, _ = fmt.Fprintf(w, `{"token_endpoint":%q,"authorization_endpoint":%q}`, server.URL+"/token", server.URL+"/authorize")
+	}))
+	defer server.Close()
+
+	tr := &Translator{GatewayControllerName: "gateway.envoyproxy.io/gatewayclass-controller"}
+	tr.oidcDiscoveryCache = newOIDCDiscoveryCache()
+
+	cfg, err := tr.fetchEndpointsFromIssuer(server.URL, nil)
+	require.NoError(t, err)
+	require.NotNil(t, cfg)
+	require.Equal(t, int32(1), callCount.Load())
+
+	cfgCached, err := tr.fetchEndpointsFromIssuer(server.URL, nil)
+	require.NoError(t, err)
+	require.NotNil(t, cfgCached)
+	require.Equal(t, int32(1), callCount.Load(), "second fetch should use cache")
+
+	cfgAgain, err := tr.fetchEndpointsFromIssuer(server.URL, nil)
+	require.NoError(t, err)
+	require.NotNil(t, cfgAgain)
+	require.Equal(t, int32(1), callCount.Load(), "subsequent fetch should continue using cache")
+}
+
+func TestTranslatorFetchEndpointsFromIssuerCacheError(t *testing.T) {
+	var (
+		callCount atomic.Int32
+		server    *httptest.Server
+	)
+
+	server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.URL.Path != "/.well-known/openid-configuration" {
+			http.NotFound(w, r)
+			return
+		}
+
+		callCount.Add(1)
+		http.NotFound(w, r)
+	}))
+	defer server.Close()
+
+	tr := &Translator{GatewayControllerName: "gateway.envoyproxy.io/gatewayclass-controller"}
+	tr.oidcDiscoveryCache = newOIDCDiscoveryCache()
+
+	cfg, err := tr.fetchEndpointsFromIssuer(server.URL, nil)
+	require.Error(t, err)
+	require.Nil(t, cfg)
+	require.Equal(t, int32(1), callCount.Load())
+
+	cfgCached, err := tr.fetchEndpointsFromIssuer(server.URL, nil)
+	require.Error(t, err)
+	require.Nil(t, cfgCached)
+	require.Equal(t, int32(1), callCount.Load(), "second fetch should use cached error")
+
+	cfgAfter, err := tr.fetchEndpointsFromIssuer(server.URL, nil)
+	require.Error(t, err)
+	require.Nil(t, cfgAfter)
+	require.Equal(t, int32(1), callCount.Load(), "subsequent fetch should continue using cached error")
+}
+
 // / tiny helper to build a minimal SecurityPolicy
 func sp(ns, name string) *egv1a1.SecurityPolicy {
 	return &egv1a1.SecurityPolicy{
diff --git a/internal/gatewayapi/testdata/envoyproxy-gateway-accesslog-with-bad-sinks.out.yaml b/internal/gatewayapi/testdata/envoyproxy-gateway-accesslog-with-bad-sinks.out.yaml
index a6bb55e49..5da07c49a 100644
--- a/internal/gatewayapi/testdata/envoyproxy-gateway-accesslog-with-bad-sinks.out.yaml
+++ b/internal/gatewayapi/testdata/envoyproxy-gateway-accesslog-with-bad-sinks.out.yaml
@@ -27,10 +27,5 @@ gateways:
       reason: InvalidParameters
       status: "False"
       type: Accepted
-    listeners:
-    - attachedRoutes: 0
-      conditions: null
-      name: http
-      supportedKinds: null
 infraIR: {}
 xdsIR: {}
diff --git a/internal/provider/kubernetes/controller_test.go b/internal/provider/kubernetes/controller_test.go
index 9a978495a..c3d29519b 100644
--- a/internal/provider/kubernetes/controller_test.go
+++ b/internal/provider/kubernetes/controller_test.go
@@ -1249,32 +1249,43 @@ func TestProcessSecurityPolicyObjectRefs(t *testing.T) {
 }
 
 func TestProcessServiceClusterForGatewayClass(t *testing.T) {
+	gcName := "merged-gc"
+	nsName := "envoy-gateway-system"
 	testCases := []struct {
 		name            string
 		gatewayClass    *gwapiv1.GatewayClass
 		envoyProxy      *egv1a1.EnvoyProxy
 		expectedSvcName string
+		serviceCluster  []client.Object
 	}{
 		{
 			name: "when merged gateways and no hardcoded svc name is used",
 			gatewayClass: &gwapiv1.GatewayClass{
 				ObjectMeta: metav1.ObjectMeta{
-					Name: "merged-gc",
+					Name: gcName,
 				},
 			},
 			envoyProxy:      nil,
-			expectedSvcName: proxy.ExpectedResourceHashedName("merged-gc"),
+			expectedSvcName: proxy.ExpectedResourceHashedName(gcName),
+			serviceCluster: []client.Object{
+				&corev1.Service{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      proxy.ExpectedResourceHashedName(gcName),
+						Namespace: nsName,
+					},
+				},
+			},
 		},
 		{
 			name: "when merged gateways and a hardcoded svc name is used",
 			gatewayClass: &gwapiv1.GatewayClass{
 				ObjectMeta: metav1.ObjectMeta{
-					Name: "merged-gc",
+					Name: gcName,
 				},
 			},
 			envoyProxy: &egv1a1.EnvoyProxy{
 				ObjectMeta: metav1.ObjectMeta{
-					Name: "merged-gc",
+					Name: gcName,
 				},
 				Spec: egv1a1.EnvoyProxySpec{
 					Provider: &egv1a1.EnvoyProxyProvider{
@@ -1288,6 +1299,25 @@ func TestProcessServiceClusterForGatewayClass(t *testing.T) {
 				},
 			},
 			expectedSvcName: "merged-gc-svc",
+			serviceCluster: []client.Object{
+				&corev1.Service{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "merged-gc-svc",
+						Namespace: nsName,
+					},
+				},
+			},
+		},
+		{
+			name: "non-existent proxy service",
+			gatewayClass: &gwapiv1.GatewayClass{
+				ObjectMeta: metav1.ObjectMeta{
+					Name: gcName,
+				},
+			},
+			envoyProxy:      nil,
+			expectedSvcName: proxy.ExpectedResourceHashedName(gcName),
+			serviceCluster:  nil,
 		},
 	}
 
@@ -1299,6 +1329,10 @@ func TestProcessServiceClusterForGatewayClass(t *testing.T) {
 			resourceMap := newResourceMapping()
 
 			r := newGatewayAPIReconciler(logger)
+			r.client = fakeclient.NewClientBuilder().
+				WithScheme(envoygateway.GetScheme()).
+				WithObjects(tc.serviceCluster...).
+				Build()
 			r.namespace = "envoy-gateway-system"
 
 			r.processServiceClusterForGatewayClass(tc.envoyProxy, tc.gatewayClass, resourceMap)
@@ -1309,8 +1343,12 @@ func TestProcessServiceClusterForGatewayClass(t *testing.T) {
 				Name:      gwapiv1.ObjectName(tc.expectedSvcName),
 			}
 			key := backendRefKey(&expectedRef)
-			require.Contains(t, resourceMap.allAssociatedBackendRefs, key)
-			require.Equal(t, expectedRef, resourceMap.allAssociatedBackendRefs[key])
+			if tc.serviceCluster != nil {
+				require.Contains(t, resourceMap.allAssociatedBackendRefs, key)
+				require.Equal(t, expectedRef, resourceMap.allAssociatedBackendRefs[key])
+			} else {
+				require.NotContains(t, resourceMap.allAssociatedBackendRefs, key)
+			}
 		})
 	}
 }
@@ -1324,6 +1362,7 @@ func TestProcessServiceClusterForGateway(t *testing.T) {
 		gatewayNamespacedMode  bool
 		expectedSvcName        string
 		expectedSvcNamespace   string
+		serviceCluster         []client.Object
 	}{
 		{
 			name: "no gateway namespaced mode with no hardcoded service name",
@@ -1377,6 +1416,14 @@ func TestProcessServiceClusterForGateway(t *testing.T) {
 			gatewayNamespacedMode: true,
 			expectedSvcName:       "my-gateway",
 			expectedSvcNamespace:  "app-namespace",
+			serviceCluster: []client.Object{
+				&corev1.Service{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "my-gateway",
+						Namespace: "app-namespace",
+					},
+				},
+			},
 		},
 		{
 			name: "gateway namespaced mode with hardcoded service name",
@@ -1404,6 +1451,14 @@ func TestProcessServiceClusterForGateway(t *testing.T) {
 			gatewayNamespacedMode: true,
 			expectedSvcName:       "my-gateway-svc",
 			expectedSvcNamespace:  "app-namespace",
+			serviceCluster: []client.Object{
+				&corev1.Service{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "my-gateway-svc",
+						Namespace: "app-namespace",
+					},
+				},
+			},
 		},
 		{
 			name: "no gateway namespaced mode with no hardcoded service name attached gatewayclass",
@@ -1443,6 +1498,10 @@ func TestProcessServiceClusterForGateway(t *testing.T) {
 			resourceMap := newResourceMapping()
 
 			r := newGatewayAPIReconciler(logger)
+			r.client = fakeclient.NewClientBuilder().
+				WithScheme(envoygateway.GetScheme()).
+				WithObjects(tc.serviceCluster...).
+				Build()
 			r.namespace = "envoy-gateway-system"
 			r.gatewayNamespaceMode = tc.gatewayNamespacedMode
 
@@ -1466,8 +1525,12 @@ func TestProcessServiceClusterForGateway(t *testing.T) {
 				Name:      gwapiv1.ObjectName(tc.expectedSvcName),
 			}
 			key := backendRefKey(&expectedRef)
-			require.Contains(t, resourceMap.allAssociatedBackendRefs, key)
-			require.Equal(t, expectedRef, resourceMap.allAssociatedBackendRefs[key])
+			if tc.serviceCluster != nil {
+				require.Contains(t, resourceMap.allAssociatedBackendRefs, key)
+				require.Equal(t, expectedRef, resourceMap.allAssociatedBackendRefs[key])
+			} else {
+				require.NotContains(t, resourceMap.allAssociatedBackendRefs, key)
+			}
 		})
 	}
 }