diff --git a/internal/core/client.go b/internal/core/client.go index ff9eccd..f44191c 100644 --- a/internal/core/client.go +++ b/internal/core/client.go @@ -173,44 +173,82 @@ func (c *UniversalClient) CreateGenericInstance(ctx context.Context, serviceId i } func (c *UniversalClient) doRequest(ctx context.Context, method, path string, payload interface{}) ([]byte, http.Header, error) { - var body io.Reader - if payload != nil { - b, err := json.Marshal(payload) + // User-Agent: браузерный, чтобы пройти DDoS-Guard (см. docs/ops/API_TOKENS.md). + const userAgent = "Mozilla/5.0 (compatible; Terraform-Provider-Nubes)" + const maxRetries = 3 + baseDelay := 2 * time.Second + + var lastErr error + for attempt := 0; attempt <= maxRetries; attempt++ { + if attempt > 0 { + delay := baseDelay * time.Duration(1<<(attempt-1)) + select { + case <-time.After(delay): + case <-ctx.Done(): + return nil, nil, ctx.Err() + } + } + + var body io.Reader + if payload != nil { + b, err := json.Marshal(payload) + if err != nil { + return nil, nil, err + } + body = bytes.NewBuffer(b) + } + + req, err := http.NewRequestWithContext(ctx, method, c.ApiEndpoint+path, body) if err != nil { return nil, nil, err } - body = bytes.NewBuffer(b) + + req.Close = true + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", userAgent) + if c.ApiToken != "" { + req.Header.Set("Authorization", "Bearer "+c.ApiToken) + } + + resp, err := c.HttpClient.Do(req) + if err != nil { + lastErr = err + if attempt < maxRetries { + continue + } + return nil, nil, err + } + + respBody, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + lastErr = err + if attempt < maxRetries { + continue + } + return nil, nil, err + } + + if resp.StatusCode >= 400 { + err := fmt.Errorf("API error %d: %s", resp.StatusCode, string(respBody)) + if attempt < maxRetries && method == "GET" && isRetryable(resp.StatusCode) { + lastErr = err + continue + } + return nil, nil, err + } + + return respBody, resp.Header, nil } - req, err := http.NewRequestWithContext(ctx, method, c.ApiEndpoint+path, body) - if err != nil { - return nil, nil, err - } + return nil, nil, fmt.Errorf("doRequest failed after %d retries: %w", maxRetries, lastErr) +} - // Force close connection - req.Close = true - - req.Header.Set("Content-Type", "application/json") - if c.ApiToken != "" { - req.Header.Set("Authorization", "Bearer "+c.ApiToken) - } - - resp, err := c.HttpClient.Do(req) - if err != nil { - return nil, nil, err - } - defer resp.Body.Close() - - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return nil, nil, err - } - - if resp.StatusCode >= 400 { - return nil, nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(respBody)) - } - - return respBody, resp.Header, nil +func isRetryable(statusCode int) bool { + return statusCode == http.StatusTooManyRequests || + statusCode == http.StatusServiceUnavailable || + statusCode == http.StatusBadGateway || + statusCode == http.StatusGatewayTimeout } // ===== UNIVERSAL FLOW (APPEND-ONLY) =====