Skip to content

Commit a22fe4b

Browse files
Add lambda janitor plugin (#1569)
Signed-off-by: Alex Demicev <alex.demicev@lambdal.com>
1 parent 362e7ad commit a22fe4b

11 files changed

Lines changed: 1833 additions & 19 deletions

File tree

commons/pkg/lambda/client.go

Lines changed: 121 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@
1717
package lambda
1818

1919
import (
20+
"bytes"
2021
"context"
2122
"encoding/json"
23+
"errors"
2224
"fmt"
2325
"io"
2426
"log/slog"
@@ -41,22 +43,32 @@ const (
4143
defaultInitialBackoff = 500 * time.Millisecond
4244
defaultBackoffFactor = 2.0
4345
defaultBackoffJitter = 0.2
46+
47+
// maxRedirects mirrors net/http's own cap, which is dropped the moment
48+
// CheckRedirect is set.
49+
maxRedirects = 10
4450
)
4551

52+
// errRedirect marks a redirect this client refused to follow. A redirect chain
53+
// is deterministic, so retrying only replays it.
54+
var errRedirect = errors.New("redirect refused")
55+
4656
// Client is an authenticated HTTP client for the Lambda Cloud API.
4757
// The API key is read from LAMBDA_API_KEY on every request so credential
4858
// rotation works without a process restart.
4959
//
5060
// Requests are retried with exponential backoff on transient failures
5161
// (network errors, 5xx responses, and 429 Too Many Requests). Permanent
5262
// failures (4xx other than 429, malformed responses, missing API key)
53-
// short-circuit the retry loop.
63+
// short-circuit the retry loop. Post retries on less, see retryRateLimitOnly.
5464
type Client struct {
5565
endpoint string
5666
http *http.Client
5767
retry retryPolicy
5868
}
5969

70+
// retryPolicy is the exponential-backoff schedule applied to a request.
71+
// maxAttempts counts the initial attempt.
6072
type retryPolicy struct {
6173
maxAttempts int
6274
initialBackoff time.Duration
@@ -114,13 +126,70 @@ func NewClient(endpoint string, opts ...Option) *Client {
114126
o(c)
115127
}
116128

129+
// Wrapped rather than assigned outright, and after the options so an
130+
// injected client cannot opt out of the scheme check.
131+
c.http.CheckRedirect = refuseInsecureRedirect(c.http.CheckRedirect)
132+
117133
return c
118134
}
119135

136+
// redirectPolicy is net/http's CheckRedirect signature.
137+
type redirectPolicy func(req *http.Request, via []*http.Request) error
138+
139+
// refuseInsecureRedirect wraps next with a scheme check. net/http copies
140+
// Authorization to the same host or a subdomain without looking at the scheme,
141+
// so a downgrade would hand the API key to a plaintext listener.
142+
//
143+
// Setting CheckRedirect at all replaces net/http's ten-redirect cap, so with no
144+
// next to defer to this reimposes it. Without that an https to https loop runs
145+
// until the client timeout.
146+
func refuseInsecureRedirect(next redirectPolicy) redirectPolicy {
147+
return func(req *http.Request, via []*http.Request) error {
148+
if req.URL.Scheme != "https" {
149+
return fmt.Errorf("%w: %s would send the API key over %s",
150+
errRedirect, req.URL.Redacted(), req.URL.Scheme)
151+
}
152+
153+
if next != nil {
154+
return next(req, via)
155+
}
156+
157+
if len(via) >= maxRedirects {
158+
return fmt.Errorf("%w: stopped after %d redirects", errRedirect, maxRedirects)
159+
}
160+
161+
return nil
162+
}
163+
}
164+
120165
// Get performs an authenticated GET against endpoint+path with optional query
121166
// params and decodes the JSON response body into out. If out is nil, the body
122167
// is discarded. Retries transient failures with exponential backoff.
123168
func (c *Client) Get(ctx context.Context, path string, query url.Values, out any) error {
169+
return c.do(ctx, http.MethodGet, path, query, nil, out, retryTransient)
170+
}
171+
172+
// Post performs an authenticated POST against endpoint+path with in marshalled
173+
// as the JSON request body, and decodes the JSON response into out.
174+
//
175+
// It serves the instance-operations endpoints, which are not idempotent and
176+
// take no idempotency key, so it retries on less than Get does. See
177+
// retryRateLimitOnly.
178+
func (c *Client) Post(ctx context.Context, path string, in, out any) error {
179+
// Marshalled once so every retry resends identical bytes.
180+
payload, err := json.Marshal(in)
181+
if err != nil {
182+
return fmt.Errorf("marshal request body: %w", err)
183+
}
184+
185+
return c.do(ctx, http.MethodPost, path, nil, payload, out, retryRateLimitOnly)
186+
}
187+
188+
// do performs an authenticated request and decodes the JSON response into out.
189+
// payload is nil for methods without a request body.
190+
func (c *Client) do(
191+
ctx context.Context, method, path string, query url.Values, payload []byte, out any, retry retryScope,
192+
) error {
124193
apiKey := os.Getenv(APIKeyEnvVar)
125194
if apiKey == "" {
126195
return fmt.Errorf("env var %s is not set", APIKeyEnvVar)
@@ -146,27 +215,32 @@ func (c *Client) Get(ctx context.Context, path string, query url.Values, out any
146215
err := wait.ExponentialBackoffWithContext(ctx, backoff, func(ctx context.Context) (bool, error) {
147216
attempts++
148217

149-
body, statusCode, doErr := c.doOnce(ctx, u, apiKey)
218+
body, statusCode, doErr := c.doOnce(ctx, method, u, apiKey, payload)
150219
if doErr != nil {
151-
// Transport-level failures (dial, TLS, i/o) are transient.
220+
// Transport-level failures (dial, TLS, i/o).
221+
if !retry(statusCode, doErr) {
222+
return false, doErr
223+
}
224+
152225
lastErr = doErr
153226
slog.Debug("Lambda API request failed, will retry", "url", u, "error", doErr)
154227

155228
return false, nil
156229
}
157230

158-
if statusCode == http.StatusTooManyRequests || statusCode >= 500 {
159-
lastErr = fmt.Errorf("GET %s: status %d: %s", u, statusCode, body)
231+
if statusCode != http.StatusOK {
232+
statusErr := fmt.Errorf("%s %s: status %d: %s", method, u, statusCode, body)
233+
if !retry(statusCode, nil) {
234+
return false, statusErr
235+
}
236+
237+
lastErr = statusErr
238+
160239
slog.Debug("Lambda API returned retryable status", "url", u, "status", statusCode)
161240

162241
return false, nil
163242
}
164243

165-
if statusCode != http.StatusOK {
166-
// Permanent client error (401, 403, 404, ...) — do not retry.
167-
return false, fmt.Errorf("GET %s: status %d: %s", u, statusCode, body)
168-
}
169-
170244
return true, decodeBody(body, out)
171245
})
172246
if err == nil {
@@ -183,8 +257,32 @@ func (c *Client) Get(ctx context.Context, path string, query url.Values, out any
183257
return err
184258
}
185259

260+
// retryScope reports whether a failed attempt can be repeated. transportErr is
261+
// nil when the request completed, in which case statusCode holds the response
262+
// status.
263+
type retryScope func(statusCode int, transportErr error) bool
264+
265+
// retryTransient retries network errors, 429, and 5xx. Safe for a request that
266+
// can be repeated without side effects. A refused redirect is excluded: the
267+
// chain is deterministic, so a repeat earns the same refusal.
268+
func retryTransient(statusCode int, transportErr error) bool {
269+
if errors.Is(transportErr, errRedirect) {
270+
return false
271+
}
272+
273+
return transportErr != nil || statusCode == http.StatusTooManyRequests || statusCode >= 500
274+
}
275+
276+
// retryRateLimitOnly retries only 429, where the rate limiter rejected the
277+
// request without acting on it. A transport error or 5xx is ambiguous, the
278+
// request may already have taken effect so it surfaces on the first attempt
279+
// rather than resubmitting an operation that cannot be undone.
280+
func retryRateLimitOnly(statusCode int, transportErr error) bool {
281+
return transportErr == nil && statusCode == http.StatusTooManyRequests
282+
}
283+
186284
// decodeBody unmarshals body into out when out is non-nil, otherwise discards
187-
// body. Extracted from Get so the retry closure stays under the cyclomatic
285+
// body. Extracted from do so the retry closure stays under the cyclomatic
188286
// complexity limit.
189287
func decodeBody(body []byte, out any) error {
190288
if out == nil {
@@ -201,18 +299,27 @@ func decodeBody(body []byte, out any) error {
201299
// doOnce performs a single request. It returns the response body and status
202300
// code separately so the retry loop can classify the outcome without having
203301
// to keep the *http.Response around.
204-
func (c *Client) doOnce(ctx context.Context, u, apiKey string) ([]byte, int, error) {
205-
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
302+
func (c *Client) doOnce(ctx context.Context, method, u, apiKey string, payload []byte) ([]byte, int, error) {
303+
var reqBody io.Reader
304+
if payload != nil {
305+
reqBody = bytes.NewReader(payload)
306+
}
307+
308+
req, err := http.NewRequestWithContext(ctx, method, u, reqBody)
206309
if err != nil {
207310
return nil, 0, fmt.Errorf("build request: %w", err)
208311
}
209312

210313
req.Header.Set("Accept", "application/json")
211314
req.Header.Set("Authorization", "Bearer "+apiKey)
212315

316+
if payload != nil {
317+
req.Header.Set("Content-Type", "application/json")
318+
}
319+
213320
resp, err := c.http.Do(req)
214321
if err != nil {
215-
return nil, 0, fmt.Errorf("GET %s: %w", u, err)
322+
return nil, 0, fmt.Errorf("%s %s: %w", method, u, err)
216323
}
217324
defer resp.Body.Close()
218325

0 commit comments

Comments
 (0)