Skip to content

Commit 3f5d0ee

Browse files
authored
Merge pull request kubernetes#128497 from benluddy/cbor-request-contenttype-circuit-breaker
KEP-4222: Fall back to JSON request encoding after CBOR 415.
2 parents b845968 + 1745dfd commit 3f5d0ee

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
@@ -589,3 +589,49 @@ func TestDynamicClientCBOREnablement(t *testing.T) {
589589
})
590590
}
591591
}
592+
593+
func TestUnsupportedMediaTypeCircuitBreakerDynamicClient(t *testing.T) {
594+
framework.SetTestOnlyCBORClientFeatureGatesForTest(t, true, true)
595+
596+
server := kubeapiservertesting.StartTestServerOrDie(t, nil, framework.DefaultTestServerFlags(), framework.SharedEtcd())
597+
t.Cleanup(server.TearDownFn)
598+
599+
config := rest.CopyConfig(server.ClientConfig)
600+
601+
client, err := dynamic.NewForConfig(config)
602+
if err != nil {
603+
t.Fatal(err)
604+
}
605+
606+
if _, err := client.Resource(corev1.SchemeGroupVersion.WithResource("namespaces")).Create(
607+
context.TODO(),
608+
&unstructured.Unstructured{Object: map[string]interface{}{"metadata": map[string]interface{}{"name": "test-dynamic-client-415"}}},
609+
metav1.CreateOptions{DryRun: []string{metav1.DryRunAll}},
610+
); !errors.IsUnsupportedMediaType(err) {
611+
t.Errorf("expected to receive unsupported media type on first cbor request, got: %v", err)
612+
}
613+
614+
// Requests from this client should fall back from application/cbor to application/json.
615+
if _, err := client.Resource(corev1.SchemeGroupVersion.WithResource("namespaces")).Create(
616+
context.TODO(),
617+
&unstructured.Unstructured{Object: map[string]interface{}{"metadata": map[string]interface{}{"name": "test-dynamic-client-415"}}},
618+
metav1.CreateOptions{DryRun: []string{metav1.DryRunAll}},
619+
); err != nil {
620+
t.Errorf("expected to receive nil error on subsequent cbor request, got: %v", err)
621+
}
622+
623+
// The circuit breaker trips on a per-client basis, so it should not begin tripped for a
624+
// fresh client with identical config.
625+
client, err = dynamic.NewForConfig(config)
626+
if err != nil {
627+
t.Fatal(err)
628+
}
629+
630+
if _, err := client.Resource(corev1.SchemeGroupVersion.WithResource("namespaces")).Create(
631+
context.TODO(),
632+
&unstructured.Unstructured{Object: map[string]interface{}{"metadata": map[string]interface{}{"name": "test-dynamic-client-415"}}},
633+
metav1.CreateOptions{DryRun: []string{metav1.DryRunAll}},
634+
); !errors.IsUnsupportedMediaType(err) {
635+
t.Errorf("expected to receive unsupported media type on cbor request with fresh client, got: %v", err)
636+
}
637+
}

0 commit comments

Comments
 (0)