mirror of
https://github.com/StackExchange/dnscontrol.git
synced 2025-12-09 13:46:07 +08:00
Fixes https://github.com/StackExchange/dnscontrol/issues/3379 Thanks to @SukkaW for adding this provider! Even though you claimed to be "not familiar with Go at all" the new code looks excellent! Great job!
298 lines
7.4 KiB
Go
298 lines
7.4 KiB
Go
package vercel
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/StackExchange/dnscontrol/v4/pkg/printer"
|
|
vercelClient "github.com/vercel/terraform-provider-vercel/client"
|
|
)
|
|
|
|
type clientRequest struct {
|
|
ctx context.Context
|
|
method string
|
|
url string
|
|
body string
|
|
errorOnNoContent bool
|
|
}
|
|
|
|
func (cr *clientRequest) toHTTPRequest() (*http.Request, error) {
|
|
r, err := http.NewRequestWithContext(
|
|
cr.ctx,
|
|
cr.method,
|
|
cr.url,
|
|
strings.NewReader(cr.body),
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Use a custom user agent for dnscontrol
|
|
r.Header.Set("User-Agent", "dnscontrol https://github.com/StackExchange/dnscontrol/pull/3542")
|
|
if cr.body != "" {
|
|
r.Header.Set("Content-Type", "application/json")
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
// doRequest is a helper function for consistently requesting data from vercel.
|
|
// It implements rate limiting and retries.
|
|
func (c *vercelProvider) doRequest(req clientRequest, v interface{}, rl *rateLimiter) error {
|
|
// Use a default http client with timeout
|
|
httpClient := &http.Client{
|
|
Timeout: 5 * 60 * time.Second,
|
|
}
|
|
|
|
if rl == nil {
|
|
panic("doRequest is expecting a rate limiter but got nil, please fire an issue and ping @SukkaW")
|
|
}
|
|
|
|
for {
|
|
r, err := req.toHTTPRequest()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
r.Header.Add("Authorization", "Bearer "+c.apiToken)
|
|
|
|
rl.delayRequest()
|
|
|
|
resp, err := httpClient.Do(r)
|
|
if err != nil {
|
|
return fmt.Errorf("error doing http request: %w", err)
|
|
}
|
|
|
|
// Handle rate limiting and retries, 429 is handled here
|
|
retry, err := rl.handleResponse(resp)
|
|
|
|
if err != nil {
|
|
defer resp.Body.Close()
|
|
return err
|
|
}
|
|
if retry {
|
|
defer resp.Body.Close()
|
|
continue
|
|
}
|
|
|
|
// Process response
|
|
err = c.processResponse(resp, v, req.errorOnNoContent)
|
|
defer resp.Body.Close()
|
|
return err
|
|
}
|
|
}
|
|
|
|
func (c *vercelProvider) processResponse(resp *http.Response, v interface{}, errorOnNoContent bool) error {
|
|
responseBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return fmt.Errorf("error reading response body: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode >= 300 {
|
|
var errorResponse vercelClient.APIError
|
|
if len(responseBody) == 0 {
|
|
errorResponse.StatusCode = resp.StatusCode
|
|
return errorResponse
|
|
}
|
|
|
|
// Try to unmarshal wrapped error first
|
|
err = json.Unmarshal(responseBody, &struct {
|
|
Error *vercelClient.APIError `json:"error"`
|
|
}{
|
|
Error: &errorResponse,
|
|
})
|
|
if err != nil {
|
|
// Try to unmarshal directly if it's not wrapped in "error"
|
|
if err2 := json.Unmarshal(responseBody, &errorResponse); err2 != nil {
|
|
return fmt.Errorf("error unmarshaling response for status code %d: %w", resp.StatusCode, err)
|
|
}
|
|
}
|
|
errorResponse.StatusCode = resp.StatusCode
|
|
errorResponse.RawMessage = responseBody
|
|
return errorResponse
|
|
}
|
|
|
|
if v == nil {
|
|
return nil
|
|
}
|
|
|
|
if errorOnNoContent && resp.StatusCode == 204 {
|
|
return vercelClient.APIError{
|
|
StatusCode: 204,
|
|
Code: "no_content",
|
|
Message: "No content",
|
|
}
|
|
}
|
|
|
|
// If we expect content but got none (and not 204), that might be an issue,
|
|
// but json.Unmarshal will just do nothing if empty, or error.
|
|
if len(responseBody) > 0 {
|
|
err = json.Unmarshal(responseBody, v)
|
|
if err != nil {
|
|
return fmt.Errorf("error unmarshaling response %s: %w", responseBody, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// rateLimiter handles Vercel's rate limits
|
|
type rateLimiter struct {
|
|
mu sync.Mutex
|
|
delay time.Duration
|
|
lastRequest time.Time
|
|
resetAt time.Time
|
|
defaultLimit int64
|
|
defaultWindow time.Duration
|
|
remaining int64 // Local tracking for operations without headers
|
|
}
|
|
|
|
func newRateLimiter(limit int64, window time.Duration) *rateLimiter {
|
|
return &rateLimiter{
|
|
defaultLimit: limit,
|
|
defaultWindow: window,
|
|
remaining: limit, // Start with full (safe) quota
|
|
resetAt: time.Now().Add(window),
|
|
}
|
|
}
|
|
|
|
func (rl *rateLimiter) delayRequest() {
|
|
rl.mu.Lock()
|
|
// Check if we need to reset local quota
|
|
if time.Now().After(rl.resetAt) {
|
|
rl.remaining = rl.defaultLimit
|
|
rl.resetAt = time.Now().Add(rl.defaultWindow)
|
|
}
|
|
|
|
// When not rate-limited, include network/server latency in delay.
|
|
next := rl.lastRequest.Add(rl.delay)
|
|
if next.After(rl.resetAt) {
|
|
// Do not stack delays past the reset point.
|
|
next = rl.resetAt
|
|
}
|
|
rl.lastRequest = next
|
|
rl.mu.Unlock()
|
|
|
|
wait := time.Until(next)
|
|
if wait > 0 {
|
|
time.Sleep(wait)
|
|
}
|
|
}
|
|
|
|
func (rl *rateLimiter) handleResponse(resp *http.Response) (bool, error) {
|
|
rl.mu.Lock()
|
|
defer rl.mu.Unlock()
|
|
|
|
// Decrement local remaining count
|
|
if rl.remaining > 0 {
|
|
rl.remaining--
|
|
}
|
|
|
|
if resp.StatusCode == http.StatusTooManyRequests {
|
|
printer.Printf("Rate-Limited. URL: %q, Headers: %v\n", resp.Request.URL, resp.Header)
|
|
|
|
// Check Retry-After header first
|
|
retryAfter, err := parseHeaderAsSeconds(resp.Header, "Retry-After", 0)
|
|
if err == nil && retryAfter > 0 {
|
|
rl.delay = retryAfter
|
|
rl.lastRequest = time.Now()
|
|
return true, nil
|
|
}
|
|
|
|
// Fallback to x-ratelimit-reset if Retry-After is missing/invalid
|
|
resetAt, err := parseHeaderAsEpoch(resp.Header, "x-ratelimit-reset")
|
|
if err == nil {
|
|
rl.delay = time.Until(resetAt)
|
|
if rl.delay < 0 {
|
|
rl.delay = time.Second // Minimum delay if reset is in past
|
|
}
|
|
rl.lastRequest = time.Now()
|
|
return true, nil
|
|
}
|
|
|
|
// Default fallback if no headers
|
|
rl.delay = 5 * time.Second
|
|
rl.lastRequest = time.Now()
|
|
return true, nil
|
|
}
|
|
|
|
// Parse standard rate limit headers to proactively delay
|
|
// Vercel headers: x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset
|
|
// These headers are only present on Create and Update operations
|
|
limit, err := parseHeaderAsInt(resp.Header, "x-ratelimit-limit", -1)
|
|
if err != nil || limit == -1 {
|
|
// Update default limit if provided
|
|
// We don't update rl.defaultLimit permanently, but use it for calculation
|
|
limit = rl.defaultLimit
|
|
}
|
|
|
|
remaining, err := parseHeaderAsInt(resp.Header, "x-ratelimit-remaining", -1)
|
|
if err != nil || remaining == -1 {
|
|
// Use local tracking
|
|
remaining = rl.remaining
|
|
} else {
|
|
// Sync local tracking with server
|
|
rl.remaining = remaining
|
|
}
|
|
|
|
resetAt, err := parseHeaderAsEpoch(resp.Header, "x-ratelimit-reset")
|
|
if err == nil {
|
|
rl.resetAt = resetAt
|
|
} else {
|
|
// Use local resetAt
|
|
resetAt = rl.resetAt
|
|
}
|
|
|
|
// Apply safety factor
|
|
safeRemaining := remaining - 2
|
|
|
|
if safeRemaining <= 0 {
|
|
// Quota exhausted (safely). Wait until quota resets.
|
|
rl.delay = time.Until(resetAt)
|
|
} else if safeRemaining > limit/2 {
|
|
// Burst through half of the safe quota
|
|
rl.delay = 0
|
|
} else {
|
|
// Spread requests evenly
|
|
window := time.Until(resetAt)
|
|
if window > 0 {
|
|
rl.delay = window / time.Duration(safeRemaining+1)
|
|
} else {
|
|
rl.delay = 0
|
|
}
|
|
}
|
|
|
|
return false, nil
|
|
}
|
|
|
|
func parseHeaderAsInt(headers http.Header, headerName string, fallback int64) (int64, error) {
|
|
v := headers.Get(headerName)
|
|
if v == "" {
|
|
return fallback, nil
|
|
}
|
|
i, err := strconv.ParseInt(v, 10, 64)
|
|
if err != nil {
|
|
return fallback, err
|
|
}
|
|
return i, nil
|
|
}
|
|
|
|
func parseHeaderAsSeconds(header http.Header, headerName string, fallback time.Duration) (time.Duration, error) {
|
|
val, err := parseHeaderAsInt(header, headerName, -1)
|
|
if err != nil || val == -1 {
|
|
return fallback, err
|
|
}
|
|
return time.Duration(val) * time.Second, nil
|
|
}
|
|
|
|
func parseHeaderAsEpoch(header http.Header, headerName string) (time.Time, error) {
|
|
val, err := parseHeaderAsInt(header, headerName, -1)
|
|
if err != nil || val == -1 {
|
|
return time.Time{}, fmt.Errorf("header %s not found or invalid", headerName)
|
|
}
|
|
return time.Unix(val, 0), nil
|
|
}
|