From 12520143241d723769428899ddae66bed5fd4ef1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Tue, 30 Jun 2026 17:27:53 +0400 Subject: [PATCH] =?UTF-8?q?fix:=20add=20retry=20+=20User-Agent=20to=20doRe?= =?UTF-8?q?quest=20(P0.1=20=E2=80=94=20DDoS-Guard=20bypass=20+=20transient?= =?UTF-8?q?=20error=20handling)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- universal_rebuild/internal/core/client.go | 103 +++++++++++++++------- 1 file changed, 73 insertions(+), 30 deletions(-) diff --git a/universal_rebuild/internal/core/client.go b/universal_rebuild/internal/core/client.go index 71288bf..2107678 100644 --- a/universal_rebuild/internal/core/client.go +++ b/universal_rebuild/internal/core/client.go @@ -802,42 +802,85 @@ func (c *UniversalClient) waitForInstanceIdle(ctx context.Context, instanceUid s // Internal HTTP helpers 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). + // Go-http-client по умолчанию блокируется фильтром ddos-guard. + 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)) // 2s, 4s, 8s + 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 + // Сетевые ошибки — retry (кроме последней попытки) + 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 + } + + // Retry только для transient-ошибок и только для GET + if resp.StatusCode >= 400 { + if attempt < maxRetries && method == "GET" && isRetryable(resp.StatusCode) { + lastErr = formatAPIError(resp.StatusCode, respBody) + continue + } + return nil, nil, formatAPIError(resp.StatusCode, respBody) + } + + 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) +} - 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, formatAPIError(resp.StatusCode, respBody) - } - - return respBody, resp.Header, nil +// isRetryable returns true for transient HTTP errors that can be retried. +func isRetryable(statusCode int) bool { + return statusCode == http.StatusTooManyRequests || // 429 + statusCode == http.StatusServiceUnavailable || // 503 + statusCode == http.StatusBadGateway || // 502 + statusCode == http.StatusGatewayTimeout // 504 } func (c *UniversalClient) postIgnoreResponse(ctx context.Context, path string, payload interface{}, returnLocation bool) (string, error) {