refactor(core): разбить client.go (1678 строк) на 15 мелких модулей по зонам ответственности
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
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)))
|
||||
}
|
||||
Reference in New Issue
Block a user