Repository: apache/apisix-ingress-controller

Issue:
bug: when endpoints are 0, ingress will synchronize abnormally
### Current Behavior

Synchronization failed and blocked other resources.


The object should be initialized. Avoid serializing JSON to null:
https://github.com/apache/apisix-ingress-controller/blob/06981b18d3f089b14e4ead306d22a6f34d958fea/internal/adc/translator/ingress.go#L225-L227


### Expected Behavior

It can synchronize normally.

### Error Logs

```log
2025-09-01T07:05:48Z        error        client/executor.go:261        failed to run http sync for server        {"server": "http://10.108.3.145:9180", "error": "ServerAddr: http://10.108.3.145:9180, Err: HTTP 400: {\"message\":\"[\\n  {\\n    \\\"expected\\\": \\\"array\\\",\\n    \\\"code\\\": \\\"invalid_type\\\",\\n    \\\"path\\\": [\\n      \\\"services\\\",\\n      1,\\n      \\\"upstream\\\",\\n      \\\"nodes\\\"\\n    ],\\n    \\\"message\\\": \\\"Invalid input: expected array, received null\\\"\\n  }\\n]\",\"errors\":[{\"expected\":\"array\",\"code\":\"invalid_type\",\"path\":[\"services\",1,\"upstream\",\"nodes\"],\"message\":\"Invalid input: expected array, received null\"}]}"}
```

### Steps to Reproduce

1. deploy httpbin
```bash
kubectl apply -f https://raw.githubusercontent.com/apache/apisix-ingress-controller/refs/heads/v2.0.0/examples/httpbin/deployment.yaml
```

2. apply ingress resource
```yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: apisix-ingress
spec:
  ingressClassName: apisix
  rules:
    - host: httpbin.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: httpbin
                port:
                  number: 80
```

3. scale 0 for httpbin

```bash
  kubectl scale --replicas=0 deployment/httpbin-deployment
```





### Environment

- APISIX Ingress controller version (run `apisix-ingress-controller version --long`) 2.0.0-rc4
- Kubernetes cluster version (run `kubectl version`)
- OS version if running APISIX Ingress controller in a bare-metal environment (run `uname -a`)


Discussion:

Solution patch:
diff --git a/api/adc/types.go b/api/adc/types.go
index eccae1fe..618fb3ce 100644
--- a/api/adc/types.go
+++ b/api/adc/types.go
@@ -505,6 +505,20 @@ func (n *UpstreamNodes) UnmarshalJSON(p []byte) error {
 	return nil
 }
 
+// MarshalJSON implements the json.Marshaler interface for UpstreamNodes.
+// By default, Go serializes a nil slice as JSON null. However, for compatibility
+// with APISIX semantics, we want a nil UpstreamNodes to be encoded as an empty
+// array ([]) instead of null. Non-nil slices are marshaled as usual.
+//
+// See APISIX upstream nodes schema definition for details:
+// https://github.com/apache/apisix/blob/77dacda31277a31d6014b4970e36bae2a5c30907/apisix/schema_def.lua#L295-L338
+func (n UpstreamNodes) MarshalJSON() ([]byte, error) {
+	if n == nil {
+		return []byte("[]"), nil
+	}
+	return json.Marshal([]UpstreamNode(n))
+}
+
 // ComposeRouteName uses namespace, name and rule name to compose
 // the route name.
 func ComposeRouteName(namespace, name string, rule string) string {
diff --git a/api/v2/apisixupstream_types.go b/api/v2/apisixupstream_types.go
index 38b41c62..c6578f91 100644
--- a/api/v2/apisixupstream_types.go
+++ b/api/v2/apisixupstream_types.go
@@ -33,7 +33,8 @@ type ApisixUpstreamSpec struct {
 	// +kubebuilder:validation:Optional
 	IngressClassName string `json:"ingressClassName,omitempty" yaml:"ingressClassName,omitempty"`
 
-	// ExternalNodes defines a static list of backend nodes located outside the cluster.
+	// ExternalNodes defines a static list of backend nodes. These can be external hosts
+	// outside the cluster or cluster-internal Services specified by their DNS name.
 	// When this field is set, the upstream will route traffic directly to these nodes
 	// without DNS resolution or service discovery.
 	// +kubebuilder:validation:Optional
diff --git a/config/crd/bases/apisix.apache.org_apisixupstreams.yaml b/config/crd/bases/apisix.apache.org_apisixupstreams.yaml
index c07b08e5..37880aba 100644
--- a/config/crd/bases/apisix.apache.org_apisixupstreams.yaml
+++ b/config/crd/bases/apisix.apache.org_apisixupstreams.yaml
@@ -65,7 +65,8 @@ spec:
                 type: object
               externalNodes:
                 description: |-
-                  ExternalNodes defines a static list of backend nodes located outside the cluster.
+                  ExternalNodes defines a static list of backend nodes. These can be external hosts
+                  outside the cluster or cluster-internal Services specified by their DNS name.
                   When this field is set, the upstream will route traffic directly to these nodes
                   without DNS resolution or service discovery.
                 items:
diff --git a/internal/adc/translator/httproute.go b/internal/adc/translator/httproute.go
index 10bcd951..4c3f6426 100644
--- a/internal/adc/translator/httproute.go
+++ b/internal/adc/translator/httproute.go
@@ -285,7 +285,7 @@ func (t *Translator) fillHTTPRoutePolicies(routes []*adctypes.Route, policies []
 }
 
 func (t *Translator) translateEndpointSlice(portName *string, weight int, endpointSlices []discoveryv1.EndpointSlice, endpointFilter func(*discoveryv1.Endpoint) bool) adctypes.UpstreamNodes {
-	var nodes adctypes.UpstreamNodes
+	nodes := adctypes.UpstreamNodes{}
 	if len(endpointSlices) == 0 {
 		return nodes
 	}
diff --git a/internal/adc/translator/ingress.go b/internal/adc/translator/ingress.go
index 7293558b..04aa7538 100644
--- a/internal/adc/translator/ingress.go
+++ b/internal/adc/translator/ingress.go
@@ -222,7 +222,7 @@ func (t *Translator) TranslateIngress(tctx *provider.TranslateContext, obj *netw
 
 // translateEndpointSliceForIngress create upstream nodes from EndpointSlice
 func (t *Translator) translateEndpointSliceForIngress(weight int, endpointSlices []discoveryv1.EndpointSlice, servicePort *corev1.ServicePort) adctypes.UpstreamNodes {
-	var nodes adctypes.UpstreamNodes
+	nodes := adctypes.UpstreamNodes{}
 	if len(endpointSlices) == 0 {
 		return nodes
 	}



Test patch:
diff --git a/docs/en/latest/reference/api-reference.md b/docs/en/latest/reference/api-reference.md
index 914742fd..1a81da84 100644
--- a/docs/en/latest/reference/api-reference.md
+++ b/docs/en/latest/reference/api-reference.md
@@ -1375,7 +1375,7 @@ definitions and custom configuration.
 | Field | Description |
 | --- | --- |
 | `ingressClassName` _string_ | IngressClassName is the name of an IngressClass cluster resource. Controller implementations use this field to determine whether they should process this ApisixUpstream resource. |
-| `externalNodes` _[ApisixUpstreamExternalNode](#apisixupstreamexternalnode) array_ | ExternalNodes defines a static list of backend nodes located outside the cluster. When this field is set, the upstream will route traffic directly to these nodes without DNS resolution or service discovery. |
+| `externalNodes` _[ApisixUpstreamExternalNode](#apisixupstreamexternalnode) array_ | ExternalNodes defines a static list of backend nodes. These can be external hosts outside the cluster or cluster-internal Services specified by their DNS name. When this field is set, the upstream will route traffic directly to these nodes without DNS resolution or service discovery. |
 | `loadbalancer` _[LoadBalancer](#loadbalancer)_ | LoadBalancer specifies the load balancer configuration for Kubernetes Service. |
 | `scheme` _string_ | Scheme is the protocol used to communicate with the upstream. Default is `http`. Can be `http`, `https`, `grpc`, or `grpcs`. |
 | `retries` _integer_ | Retries defines the number of retry attempts APISIX should make when a failure occurs. Failures include timeouts, network errors, or 5xx status codes. |
diff --git a/test/e2e/crds/v2/route.go b/test/e2e/crds/v2/route.go
index ac1e47c7..c60d01b0 100644
--- a/test/e2e/crds/v2/route.go
+++ b/test/e2e/crds/v2/route.go
@@ -471,6 +471,61 @@ spec:
 				}).WithTimeout(30 * time.Second).ProbeEvery(1 * time.Second).Should(Equal(http.StatusOK))
 			}
 		})
+
+		It("Service Endpoints Changed", func() {
+			const apisixRouteSpec = `
+apiVersion: apisix.apache.org/v2
+kind: ApisixRoute
+metadata:
+  name: default
+spec:
+  ingressClassName: %s
+  http:
+  - name: rule0
+    match:
+      hosts:
+      - httpbin
+      paths:
+      - /*
+    backends:
+    - serviceName: httpbin-service-e2e-test
+      servicePort: 80
+`
+
+			By("apply ApisixRoute")
+			var apisixRoute apiv2.ApisixRoute
+			applier.MustApplyAPIv2(types.NamespacedName{Namespace: s.Namespace(), Name: "default"},
+				&apisixRoute, fmt.Sprintf(apisixRouteSpec, s.Namespace()))
+
+			s.RequestAssert(&scaffold.RequestAssert{
+				Method: "GET",
+				Path:   "/get",
+				Host:   "httpbin",
+				Check:  scaffold.WithExpectedStatus(http.StatusOK),
+			})
+
+			By("scale httpbin deployment to 0")
+			err := s.ScaleHTTPBIN(0)
+			Expect(err).NotTo(HaveOccurred(), "scaling httpbin deployment to 0")
+
+			s.RequestAssert(&scaffold.RequestAssert{
+				Method: "GET",
+				Path:   "/get",
+				Host:   "httpbin",
+				Check:  scaffold.WithExpectedStatus(http.StatusServiceUnavailable),
+			})
+
+			By("scale httpbin deployment to 1")
+			err = s.ScaleHTTPBIN(1)
+			Expect(err).NotTo(HaveOccurred(), "scaling httpbin deployment to 1")
+
+			s.RequestAssert(&scaffold.RequestAssert{
+				Method: "GET",
+				Path:   "/get",
+				Host:   "httpbin",
+				Check:  scaffold.WithExpectedStatus(http.StatusOK),
+			})
+		})
 	})
 
 	Context("Test ApisixRoute reference ApisixUpstream", func() {
diff --git a/test/e2e/gatewayapi/httproute.go b/test/e2e/gatewayapi/httproute.go
index 06ae3c72..9bdbc7b8 100644
--- a/test/e2e/gatewayapi/httproute.go
+++ b/test/e2e/gatewayapi/httproute.go
@@ -781,6 +781,40 @@ spec:
 			applyHTTPRouteAndAssert(s, route, asserts)
 		})
 
+		It("Service Endpoints changed", func() {
+			gatewayName := s.Namespace()
+			By("create HTTPRoute")
+			s.ResourceApplied("HTTPRoute", "httpbin", fmt.Sprintf(exactRouteByGet, gatewayName), 1)
+
+			s.RequestAssert(&scaffold.RequestAssert{
+				Method: "GET",
+				Path:   "/get",
+				Host:   "httpbin.example",
+				Check:  scaffold.WithExpectedStatus(http.StatusOK),
+			})
+
+			By("scale httpbin deployment to 0")
+			err := s.ScaleHTTPBIN(0)
+			Expect(err).NotTo(HaveOccurred(), "scaling httpbin deployment to 0")
+
+			s.RequestAssert(&scaffold.RequestAssert{
+				Method: "GET",
+				Path:   "/get",
+				Host:   "httpbin.example",
+				Check:  scaffold.WithExpectedStatus(http.StatusServiceUnavailable),
+			})
+
+			By("scale httpbin deployment to 1")
+			err = s.ScaleHTTPBIN(1)
+			Expect(err).NotTo(HaveOccurred(), "scaling httpbin deployment to 1")
+
+			s.RequestAssert(&scaffold.RequestAssert{
+				Method: "GET",
+				Path:   "/get",
+				Host:   "httpbin.example",
+				Check:  scaffold.WithExpectedStatus(http.StatusOK),
+			})
+		})
 	})
 
 	Context("HTTPRoute Rule Match", func() {
diff --git a/test/e2e/ingress/ingress.go b/test/e2e/ingress/ingress.go
index ed0922cd..eb93db67 100644
--- a/test/e2e/ingress/ingress.go
+++ b/test/e2e/ingress/ingress.go
@@ -204,8 +204,7 @@ spec:
             port:
               number: 80
 `
-
-		It("Test IngressClass Selection", func() {
+		BeforeEach(func() {
 			By("create GatewayProxy")
 			gatewayProxy := fmt.Sprintf(gatewayProxyYaml, s.Namespace(), s.Deployer.GetAdminEndpoint(), s.AdminKey())
 			err := s.CreateResourceFromStringWithNamespace(gatewayProxy, s.Namespace())
@@ -217,34 +216,47 @@ spec:
 			Expect(err).NotTo(HaveOccurred(), "creating Default IngressClass")
 			time.Sleep(5 * time.Second)
 
+		})
+
+		It("Service Endpoints Changed", func() {
 			By("create Ingress without IngressClass")
-			err = s.CreateResourceFromStringWithNamespace(fmt.Sprintf(defaultIngress, s.Namespace()), s.Namespace())
+			err := s.CreateResourceFromStringWithNamespace(fmt.Sprintf(defaultIngress, s.Namespace()), s.Namespace())
 			Expect(err).NotTo(HaveOccurred(), "creating Ingress without IngressClass")
 			time.Sleep(5 * time.Second)
 
 			By("verify default ingress")
-			s.NewAPISIXClient().
-				GET("/get").
-				WithHost("default.example.com").
-				Expect().
-				Status(200)
-		})
+			s.RequestAssert(&scaffold.RequestAssert{
+				Method: "GET",
+				Path:   "/get",
+				Host:   "default.example.com",
+				Check:  scaffold.WithExpectedStatus(http.StatusOK),
+			})
 
-		It("Proxy External Service", func() {
-			By("create GatewayProxy")
-			gatewayProxy := fmt.Sprintf(gatewayProxyYaml, s.Namespace(), s.Deployer.GetAdminEndpoint(), s.AdminKey())
-			err := s.CreateResourceFromStringWithNamespace(gatewayProxy, s.Namespace())
-			Expect(err).NotTo(HaveOccurred(), "creating GatewayProxy")
-			time.Sleep(5 * time.Second)
+			err = s.ScaleHTTPBIN(0)
+			Expect(err).NotTo(HaveOccurred(), "scaling httpbin to 0")
 
-			By("create Default IngressClass")
-			err = s.CreateResourceFromStringWithNamespace(fmt.Sprintf(defaultIngressClass, s.GetControllerName(), s.Namespace()), s.Namespace())
-			Expect(err).NotTo(HaveOccurred(), "creating Default IngressClass")
-			time.Sleep(5 * time.Second)
+			s.RequestAssert(&scaffold.RequestAssert{
+				Method: "GET",
+				Path:   "/get",
+				Host:   "default.example.com",
+				Check:  scaffold.WithExpectedStatus(http.StatusServiceUnavailable),
+			})
+
+			err = s.ScaleHTTPBIN(1)
+			Expect(err).NotTo(HaveOccurred(), "scaling httpbin to 1")
 
+			s.RequestAssert(&scaffold.RequestAssert{
+				Method: "GET",
+				Path:   "/get",
+				Host:   "default.example.com",
+				Check:  scaffold.WithExpectedStatus(http.StatusOK),
+			})
+		})
+
+		It("Proxy External Service", func() {
 			By("create Ingress")
 			ingressName := s.Namespace() + "-external"
-			err = s.CreateResourceFromStringWithNamespace(fmt.Sprintf(ingressWithExternalName, ingressName), s.Namespace())
+			err := s.CreateResourceFromStringWithNamespace(fmt.Sprintf(ingressWithExternalName, ingressName), s.Namespace())
 			Expect(err).NotTo(HaveOccurred(), "creating Ingress without IngressClass")
 
 			By("checking the external service response")
@@ -259,20 +271,9 @@ spec:
 		})
 
 		It("Delete Ingress during restart", func() {
-			By("create GatewayProxy")
-			gatewayProxy := fmt.Sprintf(gatewayProxyYaml, s.Namespace(), s.Deployer.GetAdminEndpoint(), s.AdminKey())
-			err := s.CreateResourceFromStringWithNamespace(gatewayProxy, s.Namespace())
-			Expect(err).NotTo(HaveOccurred(), "creating GatewayProxy")
-			time.Sleep(5 * time.Second)
-
-			By("create Default IngressClass")
-			err = s.CreateResourceFromStringWithNamespace(fmt.Sprintf(defaultIngressClass, s.GetControllerName(), s.Namespace()), s.Namespace())
-			Expect(err).NotTo(HaveOccurred(), "creating Default IngressClass")
-			time.Sleep(5 * time.Second)
-
 			By("create Ingress with ExternalName")
 			ingressName := s.Namespace() + "-external"
-			err = s.CreateResourceFromStringWithNamespace(fmt.Sprintf(ingressWithExternalName, ingressName), s.Namespace())
+			err := s.CreateResourceFromStringWithNamespace(fmt.Sprintf(ingressWithExternalName, ingressName), s.Namespace())
 			Expect(err).NotTo(HaveOccurred(), "creating Ingress without IngressClass")
 			time.Sleep(5 * time.Second)