170 lines
4.7 KiB
Go
170 lines
4.7 KiB
Go
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)))
|
|
}
|