package core import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/http/httputil" "os" "strings" "time" ) // ===== Internal HTTP helpers ===== func (c *UniversalClient) doRequest(ctx context.Context, method, path string, payload interface{}) ([]byte, http.Header, error) { // User-Agent: браузерный, чтобы пройти DDoS-Guard (см. docs/ops/API_TOKENS.md). // Go-http-client по умолчанию блокируется фильтром ddos-guard. const maxRetries = 3 baseDelay := c.RetryBaseDelay if baseDelay <= 0 { 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) } reqURL := c.buildURL(path) req, err := http.NewRequestWithContext(ctx, method, reqURL, body) if err != nil { return nil, nil, err } // Форсируем новое TCP-соединение для POST/PUT/PATCH: DDoS-Guard может блочить их на keep-alive. // GET-запросы (поиск, чтение) оставляем на keep-alive — req.Close на них ломает DDoS-Guard. if method != "GET" { req.Close = true } req.Header.Set("User-Agent", userAgent) req.Header.Set("Accept", "*/*") if body != nil { req.Header.Set("Content-Type", "application/json") } if c.ApiToken != "" { req.Header.Set("Authorization", "Bearer "+c.ApiToken) } // DEBUG if isHTTPDebugEnabled() { reqForDump := req.Clone(req.Context()) reqForDump.Header = sanitizeAuthHeader(req.Header) dump, _ := httputil.DumpRequestOut(reqForDump, body != nil) fmt.Fprintf(os.Stderr, "\n>>> REQ %s %s\n%s\n", method, path, dump) } // DEBUG в файл if isHTTPDebugEnabled() { f, _ := os.OpenFile(debugLogPath("nubes_debug.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) if f != nil { fmt.Fprintf(f, ">>> %s %s\n", method, req.URL.String()) f.Close() } } resp, err := c.HttpClient.Do(req) if err != nil { lastErr = err // Сетевые ошибки: retry только для идемпотентного GET. if attempt < maxRetries && method == "GET" { 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 } return nil, nil, fmt.Errorf("doRequest failed after %d retries: %w", maxRetries, lastErr) } // 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) { respBody, headers, err := c.doRequest(ctx, "POST", path, payload) if err != nil { return "", err } if returnLocation { if loc := headers.Get("Location"); loc != "" { return extractUIDFromLocation(loc), nil } } var justId string if err := json.Unmarshal(respBody, &justId); err == nil && justId != "" { return justId, nil } return "", nil } func extractUIDFromLocation(loc string) string { if loc == "" { return "" } return strings.TrimPrefix(loc, "./") } // formatAPIError парсит JSON-ответ API и возвращает читаемое сообщение. // Если тело не является JSON с полем ERROR — возвращает сырой текст. func formatAPIError(statusCode int, body []byte) error { var parsed struct { Error string `json:"ERROR"` Detail string `json:"DETAIL"` } if json.Unmarshal(body, &parsed) == nil && parsed.Error != "" { msg := parsed.Error if d := strings.TrimSpace(parsed.Detail); d != "" { msg += ": " + d } return fmt.Errorf("ошибка API %d: %s", statusCode, msg) } return fmt.Errorf("ошибка API %d: %s", statusCode, strings.TrimSpace(string(body))) }