Skip to content

Commit 39e6555

Browse files
committed
certcache: on transient proxy error, fall back to upstream
1 parent 41292ad commit 39e6555

3 files changed

Lines changed: 107 additions & 20 deletions

File tree

docs/docs/howto/collateral-proxy.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ kubectl apply -f resources/
5252
With this flag, coordinators and initializers route their AMD KDS and Intel PCS collateral fetches through the proxy instead of contacting the vendor endpoints directly.
5353
Without the flag, components fetch collateral directly and the proxy isn't required.
5454

55-
The proxy is a soft dependency: if a component can't reach it, the component logs a warning and falls back to fetching directly from the vendor endpoint, then retries the proxy after a short cooldown.
55+
The proxy is a soft dependency: if a component can't reach it, or it answers with a transient error (5XX, 429), the component logs a warning and falls back to fetching directly from the vendor endpoint, then retries the proxy after a short cooldown.
5656

5757
### Internal request flow
5858

internal/attestation/certcache/cached_client.go

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ func (c *CachedHTTPSGetter) fetch(ctx context.Context, url string) (map[string][
9191
return c.ContextHTTPSGetter.GetContext(ctx, url)
9292
}
9393

94-
proxyCtx, cancel := context.WithTimeout(ctx, retryAttemptsProxy*retryInterval)
94+
proxyCtx, cancel := context.WithTimeout(ctx, c.proxyBudget(ctx))
9595
header, body, err := c.ContextHTTPSGetter.GetContext(proxyCtx, c.redirectToProxy(url))
9696
cancel()
9797
if err == nil {
@@ -100,14 +100,32 @@ func (c *CachedHTTPSGetter) fetch(ctx context.Context, url string) (map[string][
100100
return header, body, nil
101101
}
102102
var httpErr *httpError
103-
if errors.As(err, &httpErr) {
103+
if errors.As(err, &httpErr) && !transientStatus(httpErr.code) {
104104
return nil, nil, err
105105
}
106106
c.proxyRetryAfter.Store(c.clock.Now().Add(proxyRetryCooldown).UnixNano())
107-
c.logger.Warn("collateral proxy not reachable, falling back to direct upstream fetching", "url", url, "error", err, "cooldown", proxyRetryCooldown)
107+
c.logger.Warn("collateral proxy unhealthy, falling back to direct upstream fetching", "url", url, "error", err, "cooldown", proxyRetryCooldown)
108108
return c.ContextHTTPSGetter.GetContext(ctx, url)
109109
}
110110

111+
// proxyBudget is the time to spend on the proxy attempt, capped to half of what the caller granted us.
112+
func (c *CachedHTTPSGetter) proxyBudget(ctx context.Context) time.Duration {
113+
budget := time.Duration(retryAttemptsProxy) * retryInterval
114+
deadline, ok := ctx.Deadline()
115+
if !ok {
116+
return budget
117+
}
118+
return min(budget, deadline.Sub(c.clock.Now())/2)
119+
}
120+
121+
// transientStatus reports whether an HTTP status from the proxy indicates a temporary
122+
// condition on the proxy path, rather than an answer the vendor endpoint would repeat.
123+
//
124+
// Deliberately wider then [shouldRetry], which does not retry 429, but still needs to be served from cache.
125+
func transientStatus(code int) bool {
126+
return code >= 500 || code == http.StatusTooManyRequests
127+
}
128+
111129
func (c *CachedHTTPSGetter) proxyInCooldown() bool {
112130
retryAfter := c.proxyRetryAfter.Load()
113131
return retryAfter != 0 && c.clock.Now().UnixNano() < retryAfter

internal/attestation/certcache/cached_client_test.go

Lines changed: 85 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,13 @@ import (
77
"context"
88
"encoding/json"
99
"errors"
10+
"fmt"
1011
"log/slog"
12+
"net/http"
13+
"net/http/httptest"
1114
neturl "net/url"
1215
"sync"
16+
"sync/atomic"
1317
"testing"
1418
"time"
1519

@@ -28,22 +32,21 @@ func TestMain(m *testing.M) {
2832
}
2933

3034
func TestAlwaysRevalidate(t *testing.T) {
31-
const proxy = "http://collateral-proxy.default.svc"
3235
for _, tc := range []struct {
3336
name string
3437
url string
3538
want bool
3639
}{
3740
// CRLs and TCB / QE-identity must always revalidate.
3841
{"snp crl vendor", "https://kdsintf.amd.com/vcek/v1/Milan/crl", true},
39-
{"snp crl proxy", proxy + "/vcek/v1/Milan/crl", true},
42+
{"snp crl proxy", proxyBase + "/vcek/v1/Milan/crl", true},
4043
{"tdx pckcrl vendor", "https://api.trustedservices.intel.com/sgx/certification/v4/pckcrl?ca=platform&encoding=der", true},
41-
{"tdx pckcrl proxy", proxy + "/sgx/certification/v4/pckcrl?ca=platform&encoding=der", true},
42-
{"tdx tcb proxy", proxy + "/tdx/certification/v4/tcb?fmspc=abc", true},
44+
{"tdx pckcrl proxy", proxyBase + "/sgx/certification/v4/pckcrl?ca=platform&encoding=der", true},
45+
{"tdx tcb proxy", proxyBase + "/tdx/certification/v4/tcb?fmspc=abc", true},
4346
{"tdx root crl", "https://certificates.trustedservices.intel.com/IntelSGXRootCA.der", true},
4447
// VCEK certificates are immutable and served cache-first.
4548
{"vcek cert vendor", "https://kdsintf.amd.com/vcek/v1/Milan/abc123", false},
46-
{"vcek cert proxy", proxy + "/vcek/v1/Milan/abc123", false},
49+
{"vcek cert proxy", proxyBase + "/vcek/v1/Milan/abc123", false},
4750
} {
4851
t.Run(tc.name, func(t *testing.T) {
4952
assert.Equal(t, tc.want, alwaysRevalidate(tc.url))
@@ -52,16 +55,15 @@ func TestAlwaysRevalidate(t *testing.T) {
5255
}
5356

5457
func TestRedirectToProxy(t *testing.T) {
55-
const proxy = "http://collateral-proxy.default.svc"
56-
c := &CachedHTTPSGetter{collateralProxyBase: proxy}
58+
c := &CachedHTTPSGetter{collateralProxyBase: proxyBase}
5759
assert.Equal(t,
58-
proxy+"/IntelSGXRootCA.der",
60+
proxyBase+"/IntelSGXRootCA.der",
5961
c.redirectToProxy("https://certificates.trustedservices.intel.com/IntelSGXRootCA.der"))
6062
assert.Equal(t,
61-
proxy+"/vcek/v1/Milan/abc",
63+
proxyBase+"/vcek/v1/Milan/abc",
6264
c.redirectToProxy("https://kdsintf.amd.com/vcek/v1/Milan/abc"))
6365
assert.Equal(t,
64-
proxy+"/sgx/certification/v4/pckcrl?ca=platform&encoding=der",
66+
proxyBase+"/sgx/certification/v4/pckcrl?ca=platform&encoding=der",
6567
c.redirectToProxy("https://api.trustedservices.intel.com/sgx/certification/v4/pckcrl?ca=platform&encoding=der"))
6668

6769
// With no proxy configured, nothing is rewritten.
@@ -249,7 +251,6 @@ func TestContextCancellation(t *testing.T) {
249251

250252
func TestProxyFallback(t *testing.T) {
251253
const (
252-
proxyBase = "http://collateral-proxy.default.svc"
253254
proxyHost = "collateral-proxy.default.svc"
254255
kdsHost = "kdsintf.amd.com"
255256
)
@@ -263,7 +264,7 @@ func TestProxyFallback(t *testing.T) {
263264
errHosts: map[string]error{proxyHost: errors.New("dial tcp: connection refused")},
264265
body: []byte("crl-bytes"),
265266
}
266-
client, _ := newHostGetterClient(getter, proxyBase)
267+
client, _ := newHostGetterClient(getter)
267268

268269
_, body, err := client.Get(directCRL)
269270
assert.NoError(err)
@@ -285,7 +286,7 @@ func TestProxyFallback(t *testing.T) {
285286
errHosts: map[string]error{proxyHost: errors.New("dial tcp: connection refused")},
286287
body: []byte("crl-bytes"),
287288
}
288-
client, testClock := newHostGetterClient(getter, proxyBase)
289+
client, testClock := newHostGetterClient(getter)
289290

290291
_, _, err := client.Get(directCRL)
291292
assert.NoError(err)
@@ -312,25 +313,93 @@ func TestProxyFallback(t *testing.T) {
312313
hits: map[string]int{},
313314
errHosts: map[string]error{proxyHost: &httpError{code: 404, status: "404 Not Found"}},
314315
}
315-
client, _ := newHostGetterClient(getter, proxyBase)
316+
client, _ := newHostGetterClient(getter)
316317

317318
_, _, err := client.Get(directCRL)
318319
assert.Error(err)
319320
assert.Equal(1, getter.hits[proxyHost])
320321
assert.Equal(0, getter.hits[kdsHost]) // upstream not contacted
321322
assert.False(client.proxyInCooldown())
322323
})
324+
325+
for _, code := range []int{http.StatusInternalServerError, http.StatusBadGateway, http.StatusTooManyRequests} {
326+
t.Run(fmt.Sprintf("proxy answering %d falls back to upstream", code), func(t *testing.T) {
327+
assert := assert.New(t)
328+
getter := &fakeHostGetter{
329+
hits: map[string]int{},
330+
errHosts: map[string]error{proxyHost: &httpError{code: code, status: http.StatusText(code)}},
331+
body: []byte("crl-bytes"),
332+
}
333+
client, _ := newHostGetterClient(getter)
334+
335+
_, body, err := client.Get(directCRL)
336+
assert.NoError(err)
337+
assert.Equal([]byte("crl-bytes"), body)
338+
assert.Equal(1, getter.hits[proxyHost])
339+
assert.Equal(1, getter.hits[kdsHost])
340+
assert.True(client.proxyInCooldown())
341+
})
342+
}
343+
344+
t.Run("5XX survives the retrier's error wrapping", func(t *testing.T) {
345+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
346+
w.WriteHeader(http.StatusInternalServerError)
347+
}))
348+
defer srv.Close()
349+
350+
g := NewRetryHTTPSGetter(srv.Client(), 50*time.Millisecond, slog.New(slog.DiscardHandler))
351+
ctx, cancel := context.WithTimeout(t.Context(), 250*time.Millisecond)
352+
defer cancel()
353+
_, _, err := g.GetContext(ctx, srv.URL)
354+
355+
var httpErr *httpError
356+
require.ErrorAs(t, err, &httpErr)
357+
assert.True(t, transientStatus(httpErr.code))
358+
})
359+
360+
t.Run("retrying the proxy leaves budget for the fallback", func(t *testing.T) {
361+
proxySrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
362+
w.WriteHeader(http.StatusInternalServerError)
363+
}))
364+
defer proxySrv.Close()
365+
var upstreamHits atomic.Int32
366+
upstreamSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
367+
upstreamHits.Add(1)
368+
_, _ = w.Write([]byte("crl-bytes"))
369+
}))
370+
defer upstreamSrv.Close()
371+
372+
client := &CachedHTTPSGetter{
373+
ContextHTTPSGetter: NewRetryHTTPSGetter(proxySrv.Client(), 50*time.Millisecond, slog.New(slog.DiscardHandler)),
374+
gcTicker: NeverGCTicker,
375+
clock: clock.RealClock{},
376+
cache: memstore.New[string, []byte](),
377+
logger: slog.New(slog.DiscardHandler),
378+
collateralProxyBase: proxySrv.URL,
379+
}
380+
381+
// The caller's budget is shorter than what retrying the proxy would like to spend.
382+
ctx, cancel := context.WithTimeout(t.Context(), 300*time.Millisecond)
383+
defer cancel()
384+
_, body, err := client.GetContext(ctx, upstreamSrv.URL+"/vcek/v1/Milan/crl")
385+
require.NoError(t, err)
386+
assert.Equal(t, []byte("crl-bytes"), body)
387+
assert.Equal(t, int32(1), upstreamHits.Load())
388+
assert.True(t, client.proxyInCooldown())
389+
})
323390
}
324391

325-
func newHostGetterClient(getter *fakeHostGetter, collateralProxyBase string) (*CachedHTTPSGetter, *testingclock.FakeClock) {
392+
const proxyBase = "http://collateral-proxy.default.svc"
393+
394+
func newHostGetterClient(getter *fakeHostGetter) (*CachedHTTPSGetter, *testingclock.FakeClock) {
326395
testClock := testingclock.NewFakeClock(time.Now())
327396
return &CachedHTTPSGetter{
328397
ContextHTTPSGetter: getter,
329398
gcTicker: NeverGCTicker,
330399
clock: testClock,
331400
cache: memstore.New[string, []byte](),
332401
logger: slog.Default(),
333-
collateralProxyBase: collateralProxyBase,
402+
collateralProxyBase: proxyBase,
334403
}, testClock
335404
}
336405

0 commit comments

Comments
 (0)