1717package lambda
1818
1919import (
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.
5464type 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.
6072type 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.
123168func (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.
189287func 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