mirror of
https://github.com/vxcontrol/cloud.git
synced 2026-07-19 11:51:48 -04:00
539 lines
16 KiB
Go
539 lines
16 KiB
Go
package sdk
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"maps"
|
|
"net/http"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"golang.org/x/crypto/nacl/box"
|
|
)
|
|
|
|
var (
|
|
ErrInvalidRequest = errors.New("invalid request")
|
|
ErrInvalidConfiguration = errors.New("invalid configuration")
|
|
ErrClientInternal = errors.New("internal client error")
|
|
|
|
// server errors - needs exact match with server response.code
|
|
ErrBadGateway = errors.New("bad gateway") // temporary - can retry
|
|
ErrServerInternal = errors.New("internal server error") // temporary - can retry
|
|
ErrBadRequest = errors.New("bad request") // fatal - don't retry
|
|
ErrForbidden = errors.New("forbidden") // fatal - don't retry
|
|
ErrBlocked = errors.New("client temporarily blocked") // fatal - never retry
|
|
ErrNotFound = errors.New("not found") // fatal - don't retry
|
|
ErrTooManyRequests = errors.New("rate limit exceeded") // temporary - can retry with default delay
|
|
ErrTooManyRequestsRPM = errors.New("RPM limit exceeded") // temporary - can retry with RestTime
|
|
ErrTooManyRequestsRPH = errors.New("RPH limit exceeded") // fatal - too long wait
|
|
ErrTooManyRequestsRPD = errors.New("RPD limit exceeded") // fatal - too long wait
|
|
ErrInvalidSignature = errors.New("invalid signature") // fatal - crypto issue
|
|
ErrReplayAttack = errors.New("replay attack") // fatal - security issue
|
|
|
|
// daily/monthly quota errors - all fatal (no auto-retry).
|
|
// QuotaExceeded* carry a Retry-After cooldown via *QuotaError (use errors.As).
|
|
ErrQuotaBlocked = errors.New("endpoint not available for this license tier") // fatal - never retry
|
|
ErrQuotaExceededDaily = errors.New("daily quota exceeded") // fatal - retry after reset
|
|
ErrQuotaExceededMonthly = errors.New("monthly quota exceeded") // fatal - retry after reset
|
|
|
|
// client errors
|
|
ErrTicketFailed = errors.New("failed to get ticket")
|
|
ErrPoWFailed = errors.New("proof of work failed")
|
|
ErrCryptoFailed = errors.New("cryptographic operation failed")
|
|
ErrRequestFailed = errors.New("request execution failed")
|
|
ErrMaxRetriesExceeded = errors.New("maximum retry attempts exceeded")
|
|
)
|
|
|
|
// ServerErrorResponse represents server error response format
|
|
type ServerErrorResponse struct {
|
|
Status string `json:"status"`
|
|
Code string `json:"code"`
|
|
}
|
|
|
|
// RateLimitScope identifies which rolling-window rate limit the proxy rejected.
|
|
type RateLimitScope string
|
|
|
|
const (
|
|
RateLimitScopeGeneral RateLimitScope = "general" // no specific window
|
|
RateLimitScopeRPM RateLimitScope = "rpm" // requests-per-minute
|
|
RateLimitScopeRPH RateLimitScope = "rph" // requests-per-hour
|
|
RateLimitScopeRPD RateLimitScope = "rpd" // requests-per-day
|
|
)
|
|
|
|
// QuotaScope identifies which server's quota was exhausted.
|
|
type QuotaScope string
|
|
|
|
const (
|
|
QuotaScopeBlocked QuotaScope = "blocked" // tier has no access (never retry)
|
|
QuotaScopeDaily QuotaScope = "daily" // daily quota exhausted
|
|
QuotaScopeMonthly QuotaScope = "monthly" // monthly quota exhausted
|
|
)
|
|
|
|
// QuotaError wraps a quota sentinel with the server-advertised Retry-After cooldown.
|
|
// Use errors.Is for classification; errors.As to read Scope and RetryAfter.
|
|
type QuotaError struct {
|
|
Err error // ErrQuotaBlocked | ErrQuotaExceededDaily | ErrQuotaExceededMonthly
|
|
Scope QuotaScope // QuotaScopeBlocked | QuotaScopeDaily | QuotaScopeMonthly
|
|
RetryAfter time.Duration // 0 for QuotaScopeBlocked (never retry)
|
|
}
|
|
|
|
func (e *QuotaError) Error() string {
|
|
if e.RetryAfter > 0 {
|
|
return fmt.Sprintf("%s (retry after %s)", e.Err, e.RetryAfter)
|
|
}
|
|
return e.Err.Error()
|
|
}
|
|
|
|
func (e *QuotaError) Unwrap() error { return e.Err }
|
|
|
|
// RateLimitError wraps a rate-limit sentinel with the server-advertised Retry-After cooldown
|
|
// (seconds to the next rolling-window boundary).
|
|
// Use errors.Is for classification; errors.As to read Scope and RetryAfter.
|
|
type RateLimitError struct {
|
|
Err error // ErrTooManyRequests | ErrTooManyRequestsRPM | RPH | RPD
|
|
Scope RateLimitScope // RateLimitScopeGeneral | RateLimitScopeRPM | RPH | RPD
|
|
RetryAfter time.Duration // 0 if server sent no Retry-After header
|
|
}
|
|
|
|
func (e *RateLimitError) Error() string {
|
|
if e.RetryAfter > 0 {
|
|
return fmt.Sprintf("%s (retry after %s)", e.Err, e.RetryAfter)
|
|
}
|
|
return e.Err.Error()
|
|
}
|
|
|
|
func (e *RateLimitError) Unwrap() error { return e.Err }
|
|
|
|
// RetryAfterOf extracts the server-suggested wait duration from err, or returns 0.
|
|
// Works through any error wrapping chain, including the context+rate-limit join.
|
|
func RetryAfterOf(err error) time.Duration {
|
|
if err == nil {
|
|
return 0
|
|
}
|
|
var rle *RateLimitError
|
|
if errors.As(err, &rle) && rle.RetryAfter > 0 {
|
|
return rle.RetryAfter
|
|
}
|
|
var qe *QuotaError
|
|
if errors.As(err, &qe) && qe.RetryAfter > 0 {
|
|
return qe.RetryAfter
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// parseRetryAfter reads the Retry-After header (delay in seconds) as a duration.
|
|
func parseRetryAfter(header http.Header) time.Duration {
|
|
if header == nil {
|
|
return 0
|
|
}
|
|
v := strings.TrimSpace(header.Get("Retry-After"))
|
|
if v == "" {
|
|
return 0
|
|
}
|
|
secs, err := strconv.Atoi(v)
|
|
if err != nil || secs < 0 {
|
|
return 0
|
|
}
|
|
return time.Duration(secs) * time.Second
|
|
}
|
|
|
|
// isTemporaryError reports whether err should be retried.
|
|
// Only general/RPM rate limits retry (RPH/RPD waits are too long).
|
|
// Bare ErrExperimentTimeout is temporary; wrapped form from solvePoW is fatal.
|
|
func isTemporaryError(err error) bool {
|
|
var rle *RateLimitError
|
|
if errors.As(err, &rle) {
|
|
return errors.Is(rle.Err, ErrTooManyRequests) || errors.Is(rle.Err, ErrTooManyRequestsRPM)
|
|
}
|
|
|
|
switch err {
|
|
case ErrBadGateway, ErrServerInternal, ErrTooManyRequests, ErrTooManyRequestsRPM, ErrExperimentTimeout:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// isSuccessStatus reports whether code is a 2xx success. The proxy encrypts the body of
|
|
// every 2xx backend response, so the SDK decrypts and accepts all of them.
|
|
func isSuccessStatus(code int) bool {
|
|
return code >= 200 && code < 300
|
|
}
|
|
|
|
// parseServerError parses a server error response and returns the appropriate
|
|
// error. The response header is inspected for the Retry-After cooldown carried by
|
|
// quota-exceeded responses.
|
|
func parseServerError(statusCode int, header http.Header, body []byte) error {
|
|
if isSuccessStatus(statusCode) {
|
|
return nil
|
|
}
|
|
|
|
var serverErr ServerErrorResponse
|
|
if err := json.Unmarshal(body, &serverErr); err != nil {
|
|
return fmt.Errorf("HTTP %d: %w", statusCode, ErrRequestFailed)
|
|
}
|
|
|
|
switch serverErr.Code {
|
|
case "BadGateway":
|
|
return ErrBadGateway
|
|
case "Internal":
|
|
return ErrServerInternal
|
|
case "BadRequest":
|
|
return ErrBadRequest
|
|
case "Forbidden":
|
|
return ErrForbidden
|
|
case "Blocked":
|
|
return ErrBlocked
|
|
case "NotFound":
|
|
return ErrNotFound
|
|
case "TooManyRequests":
|
|
return &RateLimitError{Err: ErrTooManyRequests, Scope: RateLimitScopeGeneral, RetryAfter: parseRetryAfter(header)}
|
|
case "TooManyRequestsRPM":
|
|
return &RateLimitError{Err: ErrTooManyRequestsRPM, Scope: RateLimitScopeRPM, RetryAfter: parseRetryAfter(header)}
|
|
case "TooManyRequestsRPH":
|
|
return &RateLimitError{Err: ErrTooManyRequestsRPH, Scope: RateLimitScopeRPH, RetryAfter: parseRetryAfter(header)}
|
|
case "TooManyRequestsRPD":
|
|
return &RateLimitError{Err: ErrTooManyRequestsRPD, Scope: RateLimitScopeRPD, RetryAfter: parseRetryAfter(header)}
|
|
case "QuotaBlocked":
|
|
return &QuotaError{Err: ErrQuotaBlocked, Scope: QuotaScopeBlocked}
|
|
case "QuotaExceededDaily":
|
|
return &QuotaError{Err: ErrQuotaExceededDaily, Scope: QuotaScopeDaily, RetryAfter: parseRetryAfter(header)}
|
|
case "QuotaExceededMonthly":
|
|
return &QuotaError{Err: ErrQuotaExceededMonthly, Scope: QuotaScopeMonthly, RetryAfter: parseRetryAfter(header)}
|
|
default:
|
|
return fmt.Errorf("%s: %w", serverErr.Code, ErrRequestFailed)
|
|
}
|
|
}
|
|
|
|
type Option func(*sdk)
|
|
|
|
func WithTransport(transport *http.Transport) Option {
|
|
return func(s *sdk) {
|
|
if transport != nil {
|
|
s.transport = transport
|
|
}
|
|
}
|
|
}
|
|
|
|
func WithLogger(logger Logger) Option {
|
|
return func(s *sdk) {
|
|
if logger != nil {
|
|
s.logger = logger
|
|
}
|
|
}
|
|
}
|
|
|
|
func WithClient(name string, version string) Option {
|
|
return func(s *sdk) {
|
|
s.clientName = name
|
|
s.clientVersion = version
|
|
}
|
|
}
|
|
|
|
func WithPowTimeout(timeout time.Duration) Option {
|
|
return func(s *sdk) {
|
|
s.powTimeout = timeout
|
|
}
|
|
}
|
|
|
|
func WithMaxRetries(maxRetries int) Option {
|
|
return func(s *sdk) {
|
|
s.maxRetries = maxRetries
|
|
}
|
|
}
|
|
|
|
func WithLicenseKey(key string) Option {
|
|
return func(s *sdk) {
|
|
info, err := IntrospectLicenseKey(key)
|
|
if err == nil && info != nil && info.IsValid() {
|
|
s.licenseKey = decodeLicenseKey(key)
|
|
s.licenseFP = computeLicenseKeyFP(s.licenseKey)
|
|
}
|
|
}
|
|
}
|
|
|
|
func WithInstallationID(installationID [16]byte) Option {
|
|
return func(s *sdk) {
|
|
s.installationID = installationID
|
|
}
|
|
}
|
|
|
|
func WithHeaders(headers map[string]string) Option {
|
|
return func(s *sdk) {
|
|
if s.extraHeaders == nil {
|
|
s.extraHeaders = make(map[string]string, len(headers))
|
|
}
|
|
maps.Copy(s.extraHeaders, headers)
|
|
}
|
|
}
|
|
|
|
func withServerPublicKey(serverPublicKey *[32]byte) Option {
|
|
return func(s *sdk) {
|
|
s.serverPublicKey = serverPublicKey
|
|
}
|
|
}
|
|
|
|
func (s *sdk) applyExtraHeaders(req *http.Request) {
|
|
for k, v := range s.extraHeaders {
|
|
req.Header.Set(k, v)
|
|
}
|
|
}
|
|
|
|
type sdk struct {
|
|
clientName string
|
|
clientVersion string
|
|
client *http.Client
|
|
transport *http.Transport
|
|
logger Logger
|
|
powTimeout time.Duration
|
|
maxRetries int
|
|
licenseKey [10]byte
|
|
licenseFP [16]byte
|
|
installationID [16]byte
|
|
extraHeaders map[string]string
|
|
|
|
// NaCL keypair for session key encryption
|
|
clientPublicKey *[32]byte
|
|
clientPrivateKey *[32]byte
|
|
serverPublicKey *[32]byte
|
|
}
|
|
|
|
func defaultSDK() *sdk {
|
|
installationID := [16]byte(uuid.New())
|
|
|
|
return &sdk{
|
|
clientName: DefaultClientName,
|
|
clientVersion: DefaultClientVersion,
|
|
transport: DefaultTransport(),
|
|
logger: DefaultLogger(),
|
|
powTimeout: DefaultPowTimeout,
|
|
maxRetries: DefaultMaxRetries,
|
|
installationID: installationID,
|
|
serverPublicKey: getServerPublicKey(),
|
|
}
|
|
}
|
|
|
|
// Build initialises the SDK and generates typed call functions for each config entry.
|
|
// Must be called once before using any of the generated call functions.
|
|
func Build(configs []CallConfig, options ...Option) error {
|
|
sdk := defaultSDK()
|
|
for _, option := range options {
|
|
option(sdk)
|
|
}
|
|
|
|
sdk.client = &http.Client{
|
|
Transport: sdk.transport,
|
|
}
|
|
|
|
var err error
|
|
if sdk.clientPublicKey, sdk.clientPrivateKey, err = box.GenerateKey(rand.Reader); err != nil {
|
|
return fmt.Errorf("%w: failed to generate client NaCL keypair: %w", ErrClientInternal, err)
|
|
}
|
|
if sdk.clientPublicKey == nil || sdk.clientPrivateKey == nil {
|
|
return fmt.Errorf("%w: failed to generate client NaCL keypair", ErrClientInternal)
|
|
}
|
|
|
|
for idx, cfg := range configs {
|
|
if err := sdk.buildCall(cfg); err != nil {
|
|
format := "failed to build call[%d] '%s': %w: %w"
|
|
return fmt.Errorf(format, idx, cfg.Name, ErrInvalidConfiguration, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// EndpointStatus is the read/re-probe interface returned by Check.
|
|
type EndpointStatus interface {
|
|
LastError() error
|
|
AllowedRPM() int
|
|
IsReachable() bool
|
|
Recheck(ctx context.Context) error
|
|
}
|
|
|
|
// endpointStatus implements EndpointStatus.
|
|
type endpointStatus struct {
|
|
mu sync.Mutex
|
|
cfn *callFunc // nil when config was invalid at Check time
|
|
err error
|
|
allowedRPM int
|
|
}
|
|
|
|
func (es *endpointStatus) LastError() error {
|
|
es.mu.Lock()
|
|
defer es.mu.Unlock()
|
|
return es.err
|
|
}
|
|
|
|
func (es *endpointStatus) AllowedRPM() int {
|
|
es.mu.Lock()
|
|
defer es.mu.Unlock()
|
|
return es.allowedRPM
|
|
}
|
|
|
|
func (es *endpointStatus) IsReachable() bool {
|
|
es.mu.Lock()
|
|
defer es.mu.Unlock()
|
|
return es.err == nil && es.allowedRPM > 0
|
|
}
|
|
|
|
// Recheck re-probes the endpoint and updates err and allowedRPM in place.
|
|
func (es *endpointStatus) Recheck(ctx context.Context) error {
|
|
es.mu.Lock()
|
|
defer es.mu.Unlock()
|
|
|
|
if es.cfn == nil {
|
|
return es.err
|
|
}
|
|
|
|
td, err := es.cfn.fetchTicketData(es.cfn.newProbeContext(ctx))
|
|
es.allowedRPM = 0
|
|
es.err = err
|
|
if err == nil {
|
|
es.allowedRPM = int(td.AllowedRPM)
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
// EndpointStatuses maps endpoint Name → its probed status.
|
|
type EndpointStatuses map[string]EndpointStatus
|
|
|
|
// Check probes each endpoint by acquiring and solving a PoW ticket (no actual API call).
|
|
// Top-level error = SDK setup failure; per-endpoint failures are in EndpointStatus.LastError().
|
|
func Check(ctx context.Context, configs []CallConfig, options ...Option) (EndpointStatuses, error) {
|
|
s := defaultSDK()
|
|
for _, option := range options {
|
|
option(s)
|
|
}
|
|
|
|
s.client = &http.Client{Transport: s.transport}
|
|
|
|
var err error
|
|
if s.clientPublicKey, s.clientPrivateKey, err = box.GenerateKey(rand.Reader); err != nil {
|
|
return nil, fmt.Errorf("%w: failed to generate client NaCL keypair: %w", ErrClientInternal, err)
|
|
}
|
|
if s.clientPublicKey == nil || s.clientPrivateKey == nil {
|
|
return nil, fmt.Errorf("%w: failed to generate client NaCL keypair", ErrClientInternal)
|
|
}
|
|
|
|
results := make(EndpointStatuses, len(configs))
|
|
|
|
for _, cfg := range configs {
|
|
cfn, buildErr := s.buildCheckCall(cfg)
|
|
if buildErr != nil {
|
|
results[cfg.Name] = &endpointStatus{err: fmt.Errorf("%w: %w", ErrInvalidConfiguration, buildErr)}
|
|
continue
|
|
}
|
|
|
|
es := &endpointStatus{cfn: cfn}
|
|
_ = es.Recheck(ctx)
|
|
results[cfg.Name] = es
|
|
}
|
|
|
|
return results, nil
|
|
}
|
|
|
|
// buildCheckCall is a lightweight variant of buildCall: only host and name are validated.
|
|
func (s sdk) buildCheckCall(cfg CallConfig) (*callFunc, error) {
|
|
if cfg.Host == "" {
|
|
return nil, fmt.Errorf("host is required")
|
|
}
|
|
if cfg.Name == "" {
|
|
return nil, fmt.Errorf("name is required")
|
|
}
|
|
return &callFunc{sdk: s, cfg: cfg}, nil
|
|
}
|
|
|
|
func (s sdk) buildCall(cfg CallConfig) error {
|
|
if cfg.Host == "" {
|
|
return fmt.Errorf("host is required")
|
|
}
|
|
|
|
if cfg.Name == "" {
|
|
return fmt.Errorf("name is required")
|
|
}
|
|
|
|
for _, call := range cfg.Calls {
|
|
if err := checkCallType(call); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
switch cfg.Method {
|
|
case CallMethodGET, CallMethodDELETE:
|
|
if slices.ContainsFunc(cfg.Calls, isCallWithBody) {
|
|
return fmt.Errorf("call with body is not supported for GET and DELETE methods")
|
|
}
|
|
case CallMethodPUT, CallMethodPATCH:
|
|
if !slices.ContainsFunc(cfg.Calls, isCallWithBody) {
|
|
return fmt.Errorf("call with body is required for POST, PUT and PATCH methods")
|
|
}
|
|
fallthrough
|
|
case CallMethodPOST:
|
|
if slices.ContainsFunc(cfg.Calls, isCallWithQuery) {
|
|
return fmt.Errorf("call with query is not supported for POST, PUT and PATCH methods")
|
|
}
|
|
default:
|
|
return fmt.Errorf("invalid call method: '%s'", cfg.Method)
|
|
}
|
|
|
|
pathGenerator, argsNumber, err := s.parsePath(cfg.Path)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid path: '%s': %w", cfg.Path, err)
|
|
}
|
|
|
|
for _, call := range cfg.Calls {
|
|
if argsNumber > 0 && !isCallWithArgs(call) {
|
|
return fmt.Errorf("call with position arguments must use variant call type with args")
|
|
}
|
|
}
|
|
|
|
if err := fillCallFunc(cfg, s, pathGenerator); err != nil {
|
|
return fmt.Errorf("failed to fill call func: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// parsePath parses the path and returns the path template and position arguments number
|
|
func (s sdk) parsePath(p string) (pathGenerator, int, error) {
|
|
parts := make([]string, 0)
|
|
names := make([]string, 0)
|
|
indices := make([]int, 0)
|
|
for idx, part := range strings.Split(p, "/") {
|
|
if strings.HasPrefix(part, ":") {
|
|
indices = append(indices, idx)
|
|
names = append(names, strings.TrimPrefix(part, ":"))
|
|
}
|
|
parts = append(parts, part)
|
|
}
|
|
|
|
return func(args []string) (string, error) {
|
|
if len(indices) == 0 {
|
|
return p, nil
|
|
}
|
|
|
|
if len(args) == 0 {
|
|
return "", fmt.Errorf("no arguments provided: must be %d: %v", len(indices), names)
|
|
}
|
|
if len(args) != len(indices) {
|
|
return "", fmt.Errorf("invalid number of arguments: must be %d: %v", len(indices), names)
|
|
}
|
|
|
|
parts = slices.Clone(parts)
|
|
for idx, arg := range args {
|
|
parts[indices[idx]] = arg
|
|
}
|
|
|
|
return strings.Join(parts, "/"), nil
|
|
}, len(indices), nil
|
|
}
|