This is an automated email from the ASF dual-hosted git repository.
dongjoon-hyun pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/spark-kubernetes-operator.git
The following commit(s) were added to refs/heads/main by this push:
new 30f2af3e [SPARK-60020] Fix `KubernetesMetricsInterceptor` exceptions
for `namespaces` paths and out-of-range response codes
30f2af3e is described below
commit 30f2af3e33a23f894ec6a1b261cb94a0fdb25f4c
Author: Dongjoon Hyun <[email protected]>
AuthorDate: Wed Oct 7 08:45:40 2026 -0700
[SPARK-60020] Fix `KubernetesMetricsInterceptor` exceptions for
`namespaces` paths and out-of-range response codes
### What changes were proposed in this pull request?
This PR aims to fix two robustness bugs of `KubernetesMetricsInterceptor`.
- `parseNamespaceScopedResource` returns `Optional.empty()` unless the
request path has `namespaces/<namespace>/<resource>` segments. It matches the
`namespaces` path segment instead of the first `namespaces` substring.
- `updateResponseMetrics` marks a response code group meter, `1xx` to
`5xx`, only when the response code belongs to one of them.
### Why are the changes needed?
`parseNamespaceScopedResource` assumes that `<namespace>/<resource>` always
follows the first `namespaces` substring of the path. It throws for the
following paths.
| Path | Request | Exception |
| --- | --- | --- |
| `/api/v1/namespaces` | List or watch of namespaces |
`StringIndexOutOfBoundsException` |
| `/api/v1/namespaces/spark-system` | Get of a namespace |
`ArrayIndexOutOfBoundsException` |
| `/apis/rbac.authorization.k8s.io/v1/clusterroles/namespaces-reader` | Get
of a cluster-scoped resource whose name contains `namespaces` |
`ArrayIndexOutOfBoundsException` |
Since fabric8 7.8.0 `StandardHttpClient` calls `Interceptor.before(...)`
synchronously, the exception fails the request itself before it is sent, e.g.
`client.namespaces().list()` throws `StringIndexOutOfBoundsException`.
On `main`, the Kueue integration sends the last kind of request. It gets
the following cluster-scoped objects by name, and the client metrics are
enabled by default.
- `WorkloadPriorityClass` of the `kueue.x-k8s.io/priority-class` label
- `PriorityClass` of a pod template
- `ResourceFlavor` which Kueue assigned
For example, a `ResourceFlavor` named `all-namespaces` gives
`/apis/kueue.x-k8s.io/v1beta2/resourceflavors/all-namespaces`, which throws
`StringIndexOutOfBoundsException` like `/api/v1/namespaces`. Since the Kueue
code catches only `KubernetesClientException`, `IllegalStateException` and
`IllegalArgumentException`, it reaches the generic `catch (Exception e)` of
`AppInitStep` and `ClusterInitStep`, and the `SparkApplication` or
`SparkCluster` fails with `SchedulingFailure`. 1.0.0 has [...]
`updateResponseMetrics` uses `response.code() / 100 - 1` as the index of
the group meters. Netty accepts any non-negative status code, e.g. `HTTP/1.1
999` from a proxy, and fabric8 passes it to `Interceptor.after(...)`. For a
code outside of `100` to `599`, `responseCodeGroupMeters.get(...)` throws
`IndexOutOfBoundsException` on the Vert.x event loop. fabric8 calls
`after(...)` of all interceptors in one loop of a `thenAccept` callback whose
future it drops, so the exception silently [...]
Both bugs exist in the latest release.
- https://github.com/apache/spark-kubernetes-operator/releases/tag/1.0.0
(2026-07-26)
### Does this PR introduce _any_ user-facing change?
No for 1.0.0. On `main`, a `SparkApplication` or `SparkCluster` with Kueue
no longer fails with `SchedulingFailure` when the name of its priority class or
`ResourceFlavor` contains `namespaces`. Otherwise, the metrics are unchanged.
For a response code outside of `100` to `599`,
`kubernetes.client.http.response` and `kubernetes.client.http.response.<code>`
are recorded as before, without the exception.
### How was this patch tested?
Pass the CIs with the newly added test cases. Without this fix,
`testParseNamespaceScopedResource` fails with `StringIndexOutOfBoundsException`
and `testResponseCodeGroups` fails with `IndexOutOfBoundsException`.
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Opus 5.5
Closes #947 from dongjoon-hyun/SPARK-60020.
Authored-by: Dongjoon Hyun <[email protected]>
Signed-off-by: Dongjoon Hyun <[email protected]>
---
.../source/KubernetesMetricsInterceptor.java | 18 +++++----
.../source/KubernetesMetricsInterceptorTest.java | 46 ++++++++++++++++++++++
2 files changed, 57 insertions(+), 7 deletions(-)
diff --git
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/metrics/source/KubernetesMetricsInterceptor.java
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/metrics/source/KubernetesMetricsInterceptor.java
index dff97e92..e159bd34 100644
---
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/metrics/source/KubernetesMetricsInterceptor.java
+++
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/metrics/source/KubernetesMetricsInterceptor.java
@@ -23,6 +23,7 @@ import static
org.apache.spark.k8s.operator.config.SparkOperatorConf.KUBERNETES_
import java.nio.ByteBuffer;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -190,7 +191,10 @@ public class KubernetesMetricsInterceptor implements
Interceptor, Source {
responseLatency.update(latency);
getMeterByResponseCode(response.code()).mark();
if
(KUBERNETES_CLIENT_METRICS_GROUP_BY_RESPONSE_CODE_GROUP_ENABLED.getValue()) {
- responseCodeGroupMeters.get(response.code() / 100 - 1).mark();
+ int index = response.code() / 100 - 1;
+ if (index >= 0 && index < responseCodeGroupMeters.size()) {
+ responseCodeGroupMeters.get(index).mark();
+ }
}
}
@@ -217,14 +221,14 @@ public class KubernetesMetricsInterceptor implements
Interceptor, Source {
* Parses the given path to extract namespace-scoped resource information.
*
* @param path The request path.
- * @return An Optional containing a Pair of namespace and resource name, or
empty if not found.
+ * @return An Optional containing a Pair of namespace and resource name, or
empty if the path has
+ * no {@code namespaces/<namespace>/<resource>} segments.
*/
public Optional<Pair<String, String>> parseNamespaceScopedResource(String
path) {
- if (path.contains(NAMESPACES)) {
- int index = path.indexOf(NAMESPACES) + NAMESPACES.length();
- String namespaceAndResources = path.substring(index + 1);
- String[] parts = namespaceAndResources.split("/");
- return Optional.of(Pair.of(parts[0], parts[1]));
+ List<String> segments = Arrays.asList(path.split("/"));
+ int index = segments.indexOf(NAMESPACES);
+ if (index >= 0 && index + 2 < segments.size()) {
+ return Optional.of(Pair.of(segments.get(index + 1), segments.get(index +
2)));
} else {
return Optional.empty();
}
diff --git
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/metrics/source/KubernetesMetricsInterceptorTest.java
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/metrics/source/KubernetesMetricsInterceptorTest.java
index 36e16018..9fab0e18 100644
---
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/metrics/source/KubernetesMetricsInterceptorTest.java
+++
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/metrics/source/KubernetesMetricsInterceptorTest.java
@@ -20,17 +20,21 @@
package org.apache.spark.k8s.operator.metrics.source;
import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.Optional;
import com.codahale.metrics.Meter;
import com.codahale.metrics.Metric;
import io.fabric8.kubernetes.api.model.ConfigMap;
import io.fabric8.kubernetes.api.model.ObjectMeta;
import io.fabric8.kubernetes.client.KubernetesClient;
+import io.fabric8.kubernetes.client.http.HttpResponse;
import io.fabric8.kubernetes.client.http.Interceptor;
import io.fabric8.kubernetes.client.server.mock.EnableKubernetesMockClient;
import io.fabric8.kubernetes.client.server.mock.KubernetesMockServer;
@@ -45,6 +49,7 @@ import org.apache.spark.k8s.operator.SparkApplication;
import org.apache.spark.k8s.operator.client.KubernetesClientFactory;
import org.apache.spark.k8s.operator.metrics.SummingHistogram;
import org.apache.spark.k8s.operator.spec.ApplicationSpec;
+import org.apache.spark.util.Pair;
@EnableKubernetesMockClient(crud = true)
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
@@ -125,6 +130,47 @@ class KubernetesMetricsInterceptorTest {
}
}
+ @Test
+ @Order(3)
+ void testParseNamespaceScopedResource() {
+ KubernetesMetricsInterceptor metricsInterceptor = new
KubernetesMetricsInterceptor();
+ Assertions.assertEquals(
+ Optional.of(Pair.of("spark-system", "configmaps")),
+ metricsInterceptor.parseNamespaceScopedResource(
+
"/api/v1/namespaces/spark-system/configmaps/spark-job-operator-configuration"));
+ Assertions.assertEquals(
+ Optional.of(Pair.of("spark-test", "sparkapplications")),
+ metricsInterceptor.parseNamespaceScopedResource(
+
"/apis/spark.apache.org/v1/namespaces/spark-test/sparkapplications"));
+ for (String path :
+ List.of(
+ "/api/v1/namespaces",
+ "/api/v1/namespaces/spark-system",
+
"/apis/rbac.authorization.k8s.io/v1/clusterroles/namespaces-reader")) {
+ Assertions.assertEquals(
+ Optional.empty(),
metricsInterceptor.parseNamespaceScopedResource(path), path);
+ }
+ }
+
+ @Test
+ @Order(4)
+ void testResponseCodeGroups() {
+ KubernetesMetricsInterceptor metricsInterceptor = new
KubernetesMetricsInterceptor();
+ for (int code : new int[] {99, 100, 599, 600, 999}) {
+ HttpResponse<?> response = mock(HttpResponse.class);
+ when(response.code()).thenReturn(code);
+ metricsInterceptor.after(null, response, null);
+ }
+ Map<String, Meter> meters =
metricsInterceptor.metricRegistry().getMeters();
+ Assertions.assertEquals(5, meters.get("http.response").getCount());
+ Assertions.assertEquals(1, meters.get("http.response.999").getCount());
+ Assertions.assertEquals(1, meters.get("1xx").getCount());
+ Assertions.assertEquals(0, meters.get("2xx").getCount());
+ Assertions.assertEquals(0, meters.get("3xx").getCount());
+ Assertions.assertEquals(0, meters.get("4xx").getCount());
+ Assertions.assertEquals(1, meters.get("5xx").getCount());
+ }
+
private static SparkApplication createSparkApplication() {
ObjectMeta meta = new ObjectMeta();
meta.setName("sample-spark-application");
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]