Skip to content

Commit 1745dfd

Browse files
committed
Fall back to JSON request encoding after CBOR 415.
If a client is configured to encode request bodies to CBOR, but the server does not support CBOR, the server will respond with HTTP 415 (Unsupported Media Type). By feeding this response back to the RESTClient, subsequent requests can fall back to JSON, which is assumed to be acceptable.
1 parent c19ffb7 commit 1745dfd

File tree

4 files changed

+159
-7
lines changed

4 files changed

+159
-7
lines changed

staging/src/k8s.io/client-go/rest/client.go

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"os"
2525
"strconv"
2626
"strings"
27+
"sync/atomic"
2728
"time"
2829

2930
"github.com/munnerz/goautoneg"
@@ -89,7 +90,7 @@ type RESTClient struct {
8990
versionedAPIPath string
9091

9192
// content describes how a RESTClient encodes and decodes responses.
92-
content ClientContentConfig
93+
content requestClientContentConfigProvider
9394

9495
// creates BackoffManager that is passed to requests.
9596
createBackoffMgr func() BackoffManager
@@ -119,11 +120,10 @@ func NewRESTClient(baseURL *url.URL, versionedAPIPath string, config ClientConte
119120
return &RESTClient{
120121
base: &base,
121122
versionedAPIPath: versionedAPIPath,
122-
content: scrubCBORContentConfigIfDisabled(config),
123+
content: requestClientContentConfigProvider{base: scrubCBORContentConfigIfDisabled(config)},
123124
createBackoffMgr: readExpBackoffConfig,
124125
rateLimiter: rateLimiter,
125-
126-
Client: client,
126+
Client: client,
127127
}, nil
128128
}
129129

@@ -237,5 +237,60 @@ func (c *RESTClient) Delete() *Request {
237237

238238
// APIVersion returns the APIVersion this RESTClient is expected to use.
239239
func (c *RESTClient) APIVersion() schema.GroupVersion {
240-
return c.content.GroupVersion
240+
return c.content.GetClientContentConfig().GroupVersion
241+
}
242+
243+
// requestClientContentConfigProvider observes HTTP 415 (Unsupported Media Type) responses to detect
244+
// that the server does not understand CBOR. Once this has happened, future requests are forced to
245+
// use JSON so they can succeed. This is convenient for client users that want to prefer CBOR, but
246+
// also need to interoperate with older servers so requests do not permanently fail. The clients
247+
// will not default to using CBOR until at least all supported kube-apiservers have enable-CBOR
248+
// locked to true, so this path will be rarely taken. Additionally, all generated clients accessing
249+
// built-in kube resources are forced to protobuf, so those will not degrade to JSON.
250+
type requestClientContentConfigProvider struct {
251+
base ClientContentConfig
252+
253+
// Becomes permanently true if a server responds with HTTP 415 (Unsupported Media Type) to a
254+
// request with "Content-Type" header containing the CBOR media type.
255+
sawUnsupportedMediaTypeForCBOR atomic.Bool
256+
}
257+
258+
// GetClientContentConfig returns the ClientContentConfig that should be used for new requests by
259+
// this client.
260+
func (p *requestClientContentConfigProvider) GetClientContentConfig() ClientContentConfig {
261+
if !clientfeatures.TestOnlyFeatureGates.Enabled(clientfeatures.TestOnlyClientAllowsCBOR) {
262+
return p.base
263+
}
264+
265+
if sawUnsupportedMediaTypeForCBOR := p.sawUnsupportedMediaTypeForCBOR.Load(); !sawUnsupportedMediaTypeForCBOR {
266+
return p.base
267+
}
268+
269+
if mediaType, _, _ := mime.ParseMediaType(p.base.ContentType); mediaType != runtime.ContentTypeCBOR {
270+
return p.base
271+
}
272+
273+
config := p.base
274+
// The default ClientContentConfig sets ContentType to CBOR and the client has previously
275+
// received an HTTP 415 in response to a CBOR request. Override ContentType to JSON.
276+
config.ContentType = runtime.ContentTypeJSON
277+
return config
278+
}
279+
280+
// UnsupportedMediaType reports that the server has responded to a request with HTTP 415 Unsupported
281+
// Media Type.
282+
func (p *requestClientContentConfigProvider) UnsupportedMediaType(requestContentType string) {
283+
if !clientfeatures.TestOnlyFeatureGates.Enabled(clientfeatures.TestOnlyClientAllowsCBOR) {
284+
return
285+
}
286+
287+
// This could be extended to consider the Content-Encoding request header, the Accept and
288+
// Accept-Encoding response headers, the request method, and URI (as mentioned in
289+
// https://www.rfc-editor.org/rfc/rfc9110.html#section-15.5.16). The request Content-Type
290+
// header is sufficient to implement a blanket CBOR fallback mechanism.
291+
requestContentType, _, _ = mime.ParseMediaType(requestContentType)
292+
switch requestContentType {
293+
case runtime.ContentTypeCBOR, string(types.ApplyCBORPatchType):
294+
p.sawUnsupportedMediaTypeForCBOR.Store(true)
295+
}
241296
}

staging/src/k8s.io/client-go/rest/request.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ func NewRequest(c *RESTClient) *Request {
156156
timeout = c.Client.Timeout
157157
}
158158

159-
contentConfig := c.content
159+
contentConfig := c.content.GetClientContentConfig()
160160
contentTypeNotSet := len(contentConfig.ContentType) == 0
161161
if contentTypeNotSet {
162162
contentConfig.ContentType = "application/json"
@@ -188,7 +188,7 @@ func NewRequestWithClient(base *url.URL, versionedAPIPath string, content Client
188188
return NewRequest(&RESTClient{
189189
base: base,
190190
versionedAPIPath: versionedAPIPath,
191-
content: content,
191+
content: requestClientContentConfigProvider{base: content},
192192
Client: client,
193193
})
194194
}
@@ -1235,6 +1235,9 @@ func (r *Request) request(ctx context.Context, fn func(*http.Request, *http.Resp
12351235
if req.ContentLength >= 0 && !(req.Body != nil && req.ContentLength == 0) {
12361236
metrics.RequestSize.Observe(ctx, r.verb, r.URL().Host, float64(req.ContentLength))
12371237
}
1238+
if resp != nil && resp.StatusCode == http.StatusUnsupportedMediaType {
1239+
r.c.content.UnsupportedMediaType(resp.Request.Header.Get("Content-Type"))
1240+
}
12381241
retry.After(ctx, r, resp, err)
12391242

12401243
done := func() bool {

test/integration/client/client_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1984,3 +1984,51 @@ func TestCBORWithTypedClient(t *testing.T) {
19841984
t.Fatal(err)
19851985
}
19861986
}
1987+
1988+
func TestUnsupportedMediaTypeCircuitBreaker(t *testing.T) {
1989+
framework.SetTestOnlyCBORClientFeatureGatesForTest(t, true, true)
1990+
1991+
server := kubeapiservertesting.StartTestServerOrDie(t, nil, framework.DefaultTestServerFlags(), framework.SharedEtcd())
1992+
t.Cleanup(server.TearDownFn)
1993+
1994+
config := rest.CopyConfig(server.ClientConfig)
1995+
config.ContentType = "application/cbor"
1996+
config.AcceptContentTypes = "application/json"
1997+
1998+
client, err := corev1client.NewForConfig(config)
1999+
if err != nil {
2000+
t.Fatal(err)
2001+
}
2002+
2003+
if _, err := client.Namespaces().Create(
2004+
context.TODO(),
2005+
&v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "test-client-415"}},
2006+
metav1.CreateOptions{DryRun: []string{metav1.DryRunAll}},
2007+
); !apierrors.IsUnsupportedMediaType(err) {
2008+
t.Errorf("expected to receive unsupported media type on first cbor request, got: %v", err)
2009+
}
2010+
2011+
// Requests from this client should fall back from application/cbor to application/json.
2012+
if _, err := client.Namespaces().Create(
2013+
context.TODO(),
2014+
&v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "test-client-415"}},
2015+
metav1.CreateOptions{DryRun: []string{metav1.DryRunAll}},
2016+
); err != nil {
2017+
t.Errorf("expected to receive nil error on subsequent cbor request, got: %v", err)
2018+
}
2019+
2020+
// The circuit breaker trips on a per-client basis, so it should not begin tripped for a
2021+
// fresh client with identical config.
2022+
client, err = corev1client.NewForConfig(config)
2023+
if err != nil {
2024+
t.Fatal(err)
2025+
}
2026+
2027+
if _, err := client.Namespaces().Create(
2028+
context.TODO(),
2029+
&v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "test-client-415"}},
2030+
metav1.CreateOptions{DryRun: []string{metav1.DryRunAll}},
2031+
); !apierrors.IsUnsupportedMediaType(err) {
2032+
t.Errorf("expected to receive unsupported media type on cbor request with fresh client, got: %v", err)
2033+
}
2034+
}

test/integration/client/dynamic_client_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -533,3 +533,49 @@ func TestDynamicClientCBOREnablement(t *testing.T) {
533533
})
534534
}
535535
}
536+
537+
func TestUnsupportedMediaTypeCircuitBreakerDynamicClient(t *testing.T) {
538+
framework.SetTestOnlyCBORClientFeatureGatesForTest(t, true, true)
539+
540+
server := kubeapiservertesting.StartTestServerOrDie(t, nil, framework.DefaultTestServerFlags(), framework.SharedEtcd())
541+
t.Cleanup(server.TearDownFn)
542+
543+
config := rest.CopyConfig(server.ClientConfig)
544+
545+
client, err := dynamic.NewForConfig(config)
546+
if err != nil {
547+
t.Fatal(err)
548+
}
549+
550+
if _, err := client.Resource(corev1.SchemeGroupVersion.WithResource("namespaces")).Create(
551+
context.TODO(),
552+
&unstructured.Unstructured{Object: map[string]interface{}{"metadata": map[string]interface{}{"name": "test-dynamic-client-415"}}},
553+
metav1.CreateOptions{DryRun: []string{metav1.DryRunAll}},
554+
); !errors.IsUnsupportedMediaType(err) {
555+
t.Errorf("expected to receive unsupported media type on first cbor request, got: %v", err)
556+
}
557+
558+
// Requests from this client should fall back from application/cbor to application/json.
559+
if _, err := client.Resource(corev1.SchemeGroupVersion.WithResource("namespaces")).Create(
560+
context.TODO(),
561+
&unstructured.Unstructured{Object: map[string]interface{}{"metadata": map[string]interface{}{"name": "test-dynamic-client-415"}}},
562+
metav1.CreateOptions{DryRun: []string{metav1.DryRunAll}},
563+
); err != nil {
564+
t.Errorf("expected to receive nil error on subsequent cbor request, got: %v", err)
565+
}
566+
567+
// The circuit breaker trips on a per-client basis, so it should not begin tripped for a
568+
// fresh client with identical config.
569+
client, err = dynamic.NewForConfig(config)
570+
if err != nil {
571+
t.Fatal(err)
572+
}
573+
574+
if _, err := client.Resource(corev1.SchemeGroupVersion.WithResource("namespaces")).Create(
575+
context.TODO(),
576+
&unstructured.Unstructured{Object: map[string]interface{}{"metadata": map[string]interface{}{"name": "test-dynamic-client-415"}}},
577+
metav1.CreateOptions{DryRun: []string{metav1.DryRunAll}},
578+
); !errors.IsUnsupportedMediaType(err) {
579+
t.Errorf("expected to receive unsupported media type on cbor request with fresh client, got: %v", err)
580+
}
581+
}

0 commit comments

Comments
 (0)