From a72a72600a0fb4df9a5337b24ba3bb6168bb452c Mon Sep 17 00:00:00 2001 From: Dmitry Ng <19asdek91@gmail.com> Date: Thu, 18 Jun 2026 19:46:30 +0300 Subject: [PATCH] refactor: update API documentation to reflect changes in cryptographic methods, error handling, and introduce endpoint health check functionality --- API.md | 122 ++++++++++++------ README.md | 180 ++++++++++++++++++++++++-- doc.go | 16 ++- sdk/calls.go | 8 +- sdk/sdk.go | 279 +++++++++++++++++++++++++++++++++++++--- sdk/sdk_test.go | 286 +++++++++++++++++++++++++++++++++++++++++- sdk/transport.go | 76 +++++++---- sdk/transport_test.go | 225 ++++++++++++++++++++++++++++++++- 8 files changed, 1088 insertions(+), 104 deletions(-) diff --git a/API.md b/API.md index 533245f..55366a8 100644 --- a/API.md +++ b/API.md @@ -28,7 +28,7 @@ sequenceDiagram SDK->>SDK: Solve PoW puzzle (12-1024KB) SDK->>API: HTTP request with PoW signature - Note over SDK,API: AES-GCM encrypted
Ed25519 signed
Anonymized data + Note over SDK,API: AES-GCM encrypted
AES-CBC signed
Anonymized data API->>API: Validate PoW & license API->>API: Process request @@ -44,16 +44,16 @@ sequenceDiagram - **PoW System**: Memory-hard challenges (12-1024KB, 800-4000 AES iterations) with dynamic parameters for FPGA resistance - **Data Anonymization**: Comprehensive PII/secrets masking before AI troubleshooting transmission -- **Cryptographic Validation**: Ed25519 signatures with SHA-512 hashing ensure data integrity +- **Cryptographic Validation**: Ed25519 + SHA-512 signatures validate **package downloads**; AES-GCM authentication tags protect every request/response chunk - **Type Safety**: 24 strongly-typed call patterns with built-in Go model validation -- **Streaming Architecture**: Memory-efficient processing with AES-GCM chunk encryption +- **Streaming Architecture**: Memory-efficient processing with AES-GCM chunk encryption (16KB default) ## Authentication & Security All API endpoints require: - **PoW Challenge**: Memory-hard proof-of-work with more than 206M parameter combinations - **License Validation**: Cryptographic license verification with tier-based access control -- **End-to-End Encryption**: AES-GCM streaming encryption with 1KB chunks +- **End-to-End Encryption**: AES-128-GCM streaming encryption with 16KB chunks - **Forward Secrecy**: Daily server key rotation with deterministic derivation - **Data Anonymization**: Mandatory PII/secrets masking for all AI troubleshooting requests @@ -95,16 +95,17 @@ Before using any API endpoints, ensure you understand and comply with all applic 2. **Function Generation**: SDK creates typed functions for each endpoint 3. **Data Anonymization**: Mandatory PII/secrets masking for support services 4. **PoW Challenge**: Automatic challenge solving before each request -5. **Request Signing**: Ed25519 signature generation with installation ID -6. **Encryption**: AES-GCM encryption of request/response bodies -7. **Type Validation**: Go models ensure data integrity throughout +5. **Request Signing**: AES-CBC signature (nonce + timestamp + content length + CRC32) with installation ID XOR-masking +6. **Key Exchange**: NaCL box (Curve25519) encrypts the ephemeral session key to the server +7. **Encryption**: AES-128-GCM streaming encryption of request/response bodies (16KB chunks) +8. **Type Validation**: Go models ensure data integrity throughout ### Core Components - **Call Patterns**: 24 function types handle different request/response scenarios - **Data Anonymizer**: Mandatory PII/secrets masking engine with 300+ pattern recognition - **Transport Layer**: HTTP/2 with connection pooling and custom TLS configuration -- **Cryptographic Engine**: Ed25519 + AES-GCM for signatures and encryption +- **Cryptographic Engine**: NaCL box (Curve25519) for session-key exchange + AES-128-GCM for body encryption + AES-128-CBC for PoW request signatures; Ed25519 + SHA-512 used only for package-integrity validation (`models/signature.go`) - **PoW Solver**: Memory-hard algorithm implementation with configurable timeout - **License Manager**: Cryptographic license validation and tier enforcement @@ -228,29 +229,49 @@ type TicketSettings struct { ## Error Handling -All endpoints return structured error responses: +All endpoints return structured error responses. The SDK parses them into typed Go errors: ```json { "status": "error", - "code": "RATE_LIMIT_EXCEEDED", - "message": "Request rate limit exceeded", - "details": { - "current_usage": "exceeded", - "limit": "tier_based", - "reset_time": "2025-09-17T15:30:00Z" - } + "code": "TooManyRequestsRPM" } ``` -Common error codes: -- `INVALID_LICENSE`: License validation failed -- `POW_REQUIRED`: Proof-of-work challenge not solved -- `RATE_LIMIT_EXCEEDED`: Request rate limit exceeded -- `INSUFFICIENT_TIER`: Feature requires higher access tier -- `INVALID_REQUEST`: Malformed request data +### Error Codes -## SDK Integration +| Server Code | SDK Error | Retry | Notes | +|-------------|-----------|-------|-------| +| `BadGateway` | `sdk.ErrBadGateway` | Yes (3s) | Temporary backend overload | +| `Internal` | `sdk.ErrServerInternal` | Yes (3s) | Temporary server error | +| `BadRequest` | `sdk.ErrBadRequest` | No | Invalid request format | +| `Forbidden` | `sdk.ErrForbidden` | No | Invalid license or authentication | +| `NotFound` | `sdk.ErrNotFound` | No | Unknown endpoint | +| `TooManyRequests` | `*sdk.RateLimitError` (General) | Yes (5s) | General rate limit | +| `TooManyRequestsRPM` | `*sdk.RateLimitError` (RPM) | Yes (Retry-After, max 10s) | Per-minute window | +| `TooManyRequestsRPH` | `*sdk.RateLimitError` (RPH) | No | Per-hour window — too long to auto-retry | +| `TooManyRequestsRPD` | `*sdk.RateLimitError` (RPD) | No | Per-day window — too long to auto-retry | +| `QuotaBlocked` | `*sdk.QuotaError` (Blocked) | Never | Endpoint unavailable for this license tier | +| `QuotaExceededDaily` | `*sdk.QuotaError` (Daily) | No (Retry-After) | Daily quota exhausted | +| `QuotaExceededMonthly` | `*sdk.QuotaError` (Monthly) | No (Retry-After) | Monthly quota exhausted | + +### Retry-After Header + +Rate-limit and quota responses carry a `Retry-After: ` header. The SDK embeds it in +`*RateLimitError.RetryAfter` and `*QuotaError.RetryAfter`. Use `sdk.RetryAfterOf(err)` to read it +from any error without type-asserting: + +```go +if wait := sdk.RetryAfterOf(err); wait > 0 { + time.Sleep(wait) // server-suggested cooldown +} +``` + +`*RateLimitError` wraps temporary rate-limit sentinels (General/RPM are auto-retried by the SDK; +RPH/RPD are surfaced to the caller). `*QuotaError` wraps license-tier quota sentinels — all quota +errors are fatal and never auto-retried. + +### SDK Integration Use the VXControl Cloud SDK for seamless integration with the platform: @@ -293,6 +314,29 @@ err := sdk.Build(configs, ) ``` +### Endpoint Health Check + +Use `sdk.Check()` to probe endpoint reachability and inspect allowed RPM **without making an actual API call**. Useful at startup or in health-check routines: + +```go +statuses, err := sdk.Check(ctx, configs, + sdk.WithClient("MySecTool", "1.0.0"), + sdk.WithLicenseKey("XXXX-XXXX-XXXX-XXXX"), +) +if err != nil { + log.Fatal("SDK setup failed:", err) +} + +for name, s := range statuses { + log.Printf("[%s] reachable=%v allowedRPM=%d err=%v", + name, s.IsReachable(), s.AllowedRPM(), s.LastError()) +} + +// Re-probe later (e.g. after a rate-limit cooldown) +_ = statuses["check-updates"].Recheck(ctx) +``` +``` + ### Working Examples Production-ready examples are available in the [examples/](examples/) directory: @@ -404,14 +448,23 @@ The platform uses a dynamic reverse proxy for unified API management: ### Request Headers -All API requests include these headers: +Ticket request (`GET /api/v1/ticket/:name`): ``` -X-Client-Name: YourApp/1.0.0 -X-Installation-ID: stable-machine-uuid (from system.GetInstallationID()) -X-Request-ID: challenge-request-id -X-Request-Sign: base64-encoded-pow-signature -X-License-Key: encrypted-license-key (optional) -Content-Type: application/json +X-Installation-ID: +X-Request-ID: +X-Request-Key: +X-License-Key: (optional) +User-Agent: MyApp/1.0.0 sdk/1.0.0 +``` + +Main API request: +``` +X-Installation-ID: +X-Request-ID: +X-Request-Sign: +X-License-Key: (optional) +User-Agent: MyApp/1.0.0 sdk/1.0.0 +Content-Type: application/json (when body is present) ``` ### Installation ID Generation @@ -446,16 +499,11 @@ Successful responses return JSON data: } ``` -Error responses include structured details: +Error responses contain only `status` and `code` (see [Error Handling](#error-handling) for all codes): ```json { "status": "error", - "code": "RATE_LIMIT_EXCEEDED", - "message": "Request rate limit exceeded", - "details": { - "retry_after": "server_defined", - "quota_reset": "2025-09-26T15:30:00Z" - } + "code": "TooManyRequestsRPM" } ``` diff --git a/README.md b/README.md index 595ec20..8b4f724 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,9 @@ The VXControl Cloud SDK enables developers to integrate their security tools and - **Performance Optimized**: HTTP/2 support, connection pooling, streaming encryption - **Enterprise Ready**: Comprehensive error handling, retry logic, and production monitoring - **License Integration**: Built-in premium feature validation and tier management +- **Endpoint Health Probing**: `Check()` API for pre-flight connectivity and quota verification +- **Structured Rate Limit Errors**: `RateLimitError` / `QuotaError` carry server-advertised `Retry-After` cooldowns +- **Context-Safe Retries**: Cancelled context during back-off preserves the last `*RateLimitError` so callers can still read `RetryAfter` ## Quick Start @@ -132,7 +135,7 @@ graph TD C --> S[Rate Limiting] D --> T[End-to-End Encryption] D --> V[Forward Secrecy] - D --> X[Ed25519 Signatures] + D --> X[AES-CBC Request Signing] ``` ## Cloud Services Integration @@ -307,29 +310,103 @@ All API calls require solving computational challenges to prevent abuse and DDoS ## Error Handling +### Error Type Hierarchy + +The SDK defines three layers of errors: + +1. **Sentinel errors** — comparable with `errors.Is`, e.g. `sdk.ErrTooManyRequestsRPM` +2. **Wrapper types** — carry extra fields, extractable with `errors.As`: + - `*sdk.RateLimitError` — wraps RPM/RPH/RPD/general rate-limit sentinels and carries the server-advertised `Retry-After` cooldown + - `*sdk.QuotaError` — wraps license-tier quota sentinels (`Blocked`, `Daily`, `Monthly`) and carries the `Retry-After` reset cooldown +3. **Joined context errors** — when a context is cancelled during back-off, the SDK returns `fmt.Errorf("%w: %w", ctx.Err(), lastRateLimitErr)`, preserving both the context error and the rate-limit wrapper + +### RetryAfterOf Helper + +Use `sdk.RetryAfterOf(err)` to extract the server-suggested retry delay from any error, without needing to type-assert to `*RateLimitError` or `*QuotaError` directly: + +```go +response, err := client.UpdatesCheck(ctx, data) +if err != nil { + if wait := sdk.RetryAfterOf(err); wait > 0 { + log.Printf("server asks to retry after %s", wait) + time.Sleep(wait) + } +} +``` + +### RateLimitError and QuotaError + +```go +response, err := client.QueryThreats(ctx, body) +if err != nil { + // Fine-grained rate-limit classification + var rle *sdk.RateLimitError + if errors.As(err, &rle) { + switch rle.Scope { + case sdk.RateLimitScopeRPM: + // minute-window: SDK already retries automatically up to maxRetries + time.Sleep(rle.RetryAfter) + case sdk.RateLimitScopeRPH: + // hour-window: fatal, do not auto-retry + log.Printf("hourly limit reached, retry after %s", rle.RetryAfter) + case sdk.RateLimitScopeRPD: + // day-window: fatal, do not auto-retry + log.Printf("daily limit reached, retry after %s", rle.RetryAfter) + } + return + } + + // Quota / license-tier errors + var qe *sdk.QuotaError + if errors.As(err, &qe) { + switch qe.Scope { + case sdk.QuotaScopeBlocked: + log.Println("endpoint not available for this license tier") + case sdk.QuotaScopeDaily: + log.Printf("daily quota exhausted, reset in %s", qe.RetryAfter) + case sdk.QuotaScopeMonthly: + log.Printf("monthly quota exhausted, reset in %s", qe.RetryAfter) + } + return + } +} +``` + ### Automatic Retry Logic ```go -// Temporary errors (automatically retried): -// - Server overload (sdk.ErrBadGateway, sdk.ErrServerInternal) -// - Rate limits (sdk.ErrTooManyRequests, sdk.ErrTooManyRequestsRPM) -// - PoW timeouts (sdk.ErrExperimentTimeout) +// Temporary errors (automatically retried up to WithMaxRetries): +// - Server overload (sdk.ErrBadGateway, sdk.ErrServerInternal) → 3s backoff +// - General rate limits (sdk.ErrTooManyRequests) → 5s backoff +// - RPM rate limits (sdk.ErrTooManyRequestsRPM) → Retry-After header (capped at DefaultWaitTime=10s) +// - PoW timeouts (sdk.ErrExperimentTimeout) → DefaultWaitTime=10s backoff // Fatal errors (no retry): -// - Invalid requests (sdk.ErrBadRequest, sdk.ErrForbidden) -// - Missing resources (sdk.ErrNotFound) -// - Long-term limits (sdk.ErrTooManyRequestsRPH, sdk.ErrTooManyRequestsRPD) +// - Invalid requests (sdk.ErrBadRequest, sdk.ErrForbidden, sdk.ErrNotFound) +// - Long-term rate limits (sdk.ErrTooManyRequestsRPH, sdk.ErrTooManyRequestsRPD) +// - Quota errors (sdk.ErrQuotaBlocked, sdk.ErrQuotaExceededDaily, sdk.ErrQuotaExceededMonthly) ``` +The `calculateWaitTime` logic now **prefers the server-advertised `Retry-After`** value from `*RateLimitError` (capped at `DefaultWaitTime`) over fixed fallback delays. + ### Custom Error Handling ```go data, err := api.QueryThreats(ctx, []byte(threatQuery)) if err != nil { + // Check for server-suggested retry delay first (works for both RateLimitError and QuotaError) + if wait := sdk.RetryAfterOf(err); wait > 0 { + log.Printf("server suggests waiting %s before retry", wait) + } + switch { case errors.Is(err, sdk.ErrTooManyRequestsRPM): - // Wait and retry with exponential backoff - time.Sleep(60 * time.Second) + // SDK already retried automatically; wait for server-advertised window + time.Sleep(sdk.RetryAfterOf(err)) + + case errors.Is(err, sdk.ErrQuotaBlocked): + // Endpoint not available for current license tier, upgrade required + log.Error("access denied — upgrade license tier") case errors.Is(err, sdk.ErrForbidden): // Check license validity or authentication @@ -345,6 +422,78 @@ if err != nil { } ``` +## Endpoint Health Check + +The `Check()` function probes each configured endpoint by acquiring and solving a PoW ticket **without making an actual API call**. Use it at startup or in health-check routines to verify reachability and inspect allowed RPM quotas. + +### Basic Usage + +```go +configs := []sdk.CallConfig{ + {Host: "update.pentagi.com", Name: "check_updates", Path: "/api/v1/updates/check", Method: sdk.CallMethodPOST}, + {Host: "support.pentagi.com", Name: "error_report", Path: "/api/v1/errors/report", Method: sdk.CallMethodPOST}, +} + +ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) +defer cancel() + +statuses, err := sdk.Check(ctx, configs, + sdk.WithClient("MyApp", "1.0.0"), + sdk.WithLicenseKey("XXXX-XXXX-XXXX-XXXX"), +) +if err != nil { + log.Fatal("SDK setup failed:", err) +} + +for name, s := range statuses { + if s.IsReachable() { + log.Printf("[%s] reachable, allowed RPM: %d", name, s.AllowedRPM()) + } else { + log.Printf("[%s] unreachable: %v", name, s.LastError()) + } +} +``` + +### EndpointStatus Interface + +`Check()` returns `sdk.EndpointStatuses` — a `map[string]EndpointStatus` keyed by endpoint `Name`. Each value exposes: + +| Method | Description | +|--------|-------------| +| `LastError() error` | Last probe error, or `nil` on success | +| `AllowedRPM() int` | Server-advertised requests-per-minute quota (0 when unreachable) | +| `IsReachable() bool` | `true` when last probe succeeded and `AllowedRPM > 0` | +| `Recheck(ctx) error` | Re-probes the endpoint in place and updates all fields atomically | + +```go +// Re-probe a specific endpoint later (e.g. after a rate-limit cooldown) +if err := statuses["check_updates"].Recheck(ctx); err != nil { + log.Println("still unreachable:", err) +} else { + log.Println("now reachable, RPM:", statuses["check_updates"].AllowedRPM()) +} +``` + +### Error Classification in Check + +Top-level errors from `Check()` indicate SDK setup failures (crypto, invalid options). Per-endpoint failures are stored inside each `EndpointStatus` and use the same error sentinel hierarchy as regular calls: + +```go +s := statuses["error_report"] +switch { +case s.LastError() == nil: + // reachable +case errors.Is(s.LastError(), sdk.ErrInvalidConfiguration): + log.Println("bad config — fix CallConfig") +case errors.Is(s.LastError(), sdk.ErrQuotaBlocked): + log.Println("endpoint not available for this license tier") +case errors.Is(s.LastError(), sdk.ErrForbidden): + log.Println("license key rejected by server") +default: + log.Println("network/server error:", s.LastError()) +} +``` + ## Performance Characteristics ### Benchmarks @@ -558,11 +707,20 @@ response, err := client.UpdatesCheck(ctx, requestData) | Error | Type | Retry | Description | |-------|------|-------|-------------| | `sdk.ErrBadGateway` | Temporary | Yes (3s) | Server maintenance/overload | -| `sdk.ErrTooManyRequestsRPM` | Temporary | Yes (server-defined) | Rate limit exceeded | +| `sdk.ErrServerInternal` | Temporary | Yes (3s) | Internal server error | +| `sdk.ErrTooManyRequests` | Temporary | Yes (5s) | General rate limit exceeded | +| `sdk.ErrTooManyRequestsRPM` | Temporary | Yes (Retry-After, max 10s) | Per-minute rate limit exceeded | | `sdk.ErrExperimentTimeout` | Temporary | Yes (10s) | PoW solving timeout | +| `sdk.ErrTooManyRequestsRPH` | Fatal | No | Per-hour rate limit exceeded | +| `sdk.ErrTooManyRequestsRPD` | Fatal | No | Per-day rate limit exceeded | | `sdk.ErrForbidden` | Fatal | No | Invalid license or authentication | | `sdk.ErrBadRequest` | Fatal | No | Invalid request format | | `sdk.ErrNotFound` | Fatal | No | Unknown endpoint or resource | +| `sdk.ErrQuotaBlocked` | Fatal | Never | Endpoint not available for this license tier | +| `sdk.ErrQuotaExceededDaily` | Fatal | No (Retry-After via `*QuotaError`) | Daily quota exhausted | +| `sdk.ErrQuotaExceededMonthly` | Fatal | No (Retry-After via `*QuotaError`) | Monthly quota exhausted | + +> **Tip:** Use `sdk.RetryAfterOf(err)` to extract the server-suggested cooldown from any error, regardless of whether it is a `*RateLimitError` or `*QuotaError` or wrapped further in a context error. ## Available Models diff --git a/doc.go b/doc.go index 96ba868..172cff8 100644 --- a/doc.go +++ b/doc.go @@ -43,21 +43,31 @@ // }, // } // -// // Initialize SDK +// // Initialize SDK — populates all typed call functions // err := sdk.Build(configs, // sdk.WithClient("MySecTool", "1.0.0"), // sdk.WithInstallationID(system.GetInstallationID()), // sdk.WithLicenseKey("your-license-key"), // ) // +// // Optional: probe endpoints without making real API calls +// statuses, err := sdk.Check(context.Background(), configs, +// sdk.WithClient("MySecTool", "1.0.0"), +// sdk.WithLicenseKey("your-license-key"), +// ) +// // statuses["updates_check"].IsReachable(), .AllowedRPM(), .LastError(), .Recheck(ctx) +// // # Core Features // // Security-First Design: // - Memory-hard proof-of-work protection against abuse and DDoS attacks -// - Ed25519 cryptographic signatures for data integrity verification -// - AES-GCM end-to-end encryption with forward secrecy +// - NaCL (Curve25519) key exchange with AES-CBC PoW signatures and CRC32 integrity +// - AES-GCM end-to-end streaming encryption with forward secrecy +// - Ed25519 package-integrity validation (models/signature.go) // - Stable machine identification for installation tracking // - Mandatory PII/secrets anonymization for AI troubleshooting +// - Structured rate-limit and quota errors (*RateLimitError / *QuotaError) with +// server-advertised Retry-After; use RetryAfterOf(err) to extract it // // Type Safety: // - 24 strongly-typed function patterns covering all request/response scenarios diff --git a/sdk/calls.go b/sdk/calls.go index 19f0cf1..5707407 100644 --- a/sdk/calls.go +++ b/sdk/calls.go @@ -305,7 +305,7 @@ func (c *callFunc) invokeWithRetries(cctx *callContext) error { select { case <-cctx.Done(): - return cctx.Err() + return fmt.Errorf("%w: %w", cctx.Err(), err) case <-time.After(waitTime): // continue to retry } @@ -373,6 +373,12 @@ func (c *callFunc) invokeWithWriter(cctx *callContext) error { // calculateWaitTime determines how long to wait before retry based on error type and response func (c *callFunc) calculateWaitTime(err error, cctx *callContext) time.Duration { + // Prefer the server-advertised Retry-After (rate-limit 429s carry it in a + // *RateLimitError), capped by DefaultWaitTime so one retry never blocks too long. + var rle *RateLimitError + if errors.As(err, &rle) && rle.RetryAfter > 0 { + return min(rle.RetryAfter, DefaultWaitTime) + } switch { case errors.Is(err, ErrTooManyRequestsRPM) || errors.Is(err, ErrExperimentTimeout): if cctx != nil && cctx.restWaitTime > 0 { diff --git a/sdk/sdk.go b/sdk/sdk.go index c1427f9..dedadaa 100644 --- a/sdk/sdk.go +++ b/sdk/sdk.go @@ -1,13 +1,17 @@ package sdk import ( + "context" "crypto/rand" "encoding/json" "errors" "fmt" + "maps" "net/http" "slices" + "strconv" "strings" + "sync" "time" "github.com/google/uuid" @@ -20,17 +24,24 @@ var ( 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 - 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 + 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") @@ -46,8 +57,102 @@ type ServerErrorResponse struct { Code string `json:"code"` } -// isTemporaryError determines if error is temporary and can be retried +// 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 @@ -56,9 +161,17 @@ func isTemporaryError(err error) bool { } } -// parseServerError parses server error response and returns appropriate error -func parseServerError(statusCode int, body []byte) error { - if statusCode == 200 { +// 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 } @@ -76,16 +189,24 @@ func parseServerError(statusCode int, body []byte) error { return ErrBadRequest case "Forbidden": return ErrForbidden + case "Blocked": + return ErrBlocked case "NotFound": return ErrNotFound case "TooManyRequests": - return ErrTooManyRequests + return &RateLimitError{Err: ErrTooManyRequests, Scope: RateLimitScopeGeneral, RetryAfter: parseRetryAfter(header)} case "TooManyRequestsRPM": - return ErrTooManyRequestsRPM + return &RateLimitError{Err: ErrTooManyRequestsRPM, Scope: RateLimitScopeRPM, RetryAfter: parseRetryAfter(header)} case "TooManyRequestsRPH": - return ErrTooManyRequestsRPH + return &RateLimitError{Err: ErrTooManyRequestsRPH, Scope: RateLimitScopeRPH, RetryAfter: parseRetryAfter(header)} case "TooManyRequestsRPD": - return ErrTooManyRequestsRPD + 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) } @@ -144,12 +265,27 @@ func WithInstallationID(installationID [16]byte) Option { } } +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 @@ -161,6 +297,7 @@ type sdk struct { licenseKey [10]byte licenseFP [16]byte installationID [16]byte + extraHeaders map[string]string // NaCL keypair for session key encryption clientPublicKey *[32]byte @@ -183,6 +320,8 @@ func defaultSDK() *sdk { } } +// 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 { @@ -211,6 +350,108 @@ func Build(configs []CallConfig, options ...Option) error { 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") diff --git a/sdk/sdk_test.go b/sdk/sdk_test.go index ba34cbe..11a8fbc 100644 --- a/sdk/sdk_test.go +++ b/sdk/sdk_test.go @@ -1,8 +1,11 @@ package sdk import ( + "context" "encoding/binary" + "errors" "fmt" + "net/http" "testing" "time" ) @@ -98,7 +101,7 @@ func TestServerErrorParsing(t *testing.T) { } for _, tt := range tests { - err := parseServerError(tt.statusCode, tt.body) + err := parseServerError(tt.statusCode, nil, tt.body) if tt.wantErr == nil { if err != nil { t.Errorf("parseServerError(%d) error = %v, wantErr nil", tt.statusCode, err) @@ -111,6 +114,287 @@ func TestServerErrorParsing(t *testing.T) { } } +func TestParseServerError_Blocked(t *testing.T) { + err := parseServerError(http.StatusForbidden, http.Header{}, []byte(`{"status":"error","code":"Blocked"}`)) + if !errors.Is(err, ErrBlocked) { + t.Fatalf("errors.Is(ErrBlocked) failed for %v", err) + } + if errors.Is(err, ErrForbidden) { + t.Fatal("firewall block must be distinct from a tier/auth Forbidden") + } +} + +func TestErrBlocked_IsFatal(t *testing.T) { + if isTemporaryError(ErrBlocked) { + t.Fatal("a firewall block must never be auto-retried") + } +} + +func TestParseServerError_BlockedDistinctFromQuotaBlocked(t *testing.T) { + blocked := parseServerError(http.StatusForbidden, http.Header{}, []byte(`{"code":"Blocked"}`)) + quota := parseServerError(http.StatusForbidden, http.Header{}, []byte(`{"code":"QuotaBlocked"}`)) + if !errors.Is(blocked, ErrBlocked) { + t.Fatalf("errors.Is(ErrBlocked) failed for %v", blocked) + } + if !errors.Is(quota, ErrQuotaBlocked) { + t.Fatalf("errors.Is(ErrQuotaBlocked) failed for %v", quota) + } + if errors.Is(blocked, ErrQuotaBlocked) { + t.Fatal("ErrBlocked must not satisfy ErrQuotaBlocked") + } +} + +func TestParseServerError_QuotaExceededDailyWithRetryAfter(t *testing.T) { + hdr := http.Header{} + hdr.Set("Retry-After", "3600") + body := []byte(`{"status":"error","code":"QuotaExceededDaily"}`) + + err := parseServerError(http.StatusTooManyRequests, hdr, body) + + if !errors.Is(err, ErrQuotaExceededDaily) { + t.Fatalf("errors.Is(ErrQuotaExceededDaily) failed for %v", err) + } + var qe *QuotaError + if !errors.As(err, &qe) { + t.Fatalf("errors.As(*QuotaError) failed for %v", err) + } + if qe.Scope != QuotaScopeDaily { + t.Fatalf("scope = %q, want %q", qe.Scope, QuotaScopeDaily) + } + if qe.RetryAfter != time.Hour { + t.Fatalf("retry-after = %s, want 1h", qe.RetryAfter) + } +} + +func TestParseServerError_QuotaBlockedNoRetry(t *testing.T) { + body := []byte(`{"status":"error","code":"QuotaBlocked"}`) + err := parseServerError(http.StatusForbidden, nil, body) + + if !errors.Is(err, ErrQuotaBlocked) { + t.Fatalf("errors.Is(ErrQuotaBlocked) failed for %v", err) + } + var qe *QuotaError + if !errors.As(err, &qe) { + t.Fatalf("errors.As(*QuotaError) failed for %v", err) + } + if qe.RetryAfter != 0 { + t.Fatalf("blocked retry-after = %s, want 0", qe.RetryAfter) + } + // blocked is fatal and never auto-retried + if isTemporaryError(err) { + t.Fatalf("QuotaBlocked must not be temporary") + } +} + +func TestParseServerError_QuotaMonthlyIsFatal(t *testing.T) { + body := []byte(`{"status":"error","code":"QuotaExceededMonthly"}`) + err := parseServerError(http.StatusTooManyRequests, nil, body) + if !errors.Is(err, ErrQuotaExceededMonthly) { + t.Fatalf("errors.Is(ErrQuotaExceededMonthly) failed for %v", err) + } + if isTemporaryError(err) { + t.Fatalf("QuotaExceededMonthly must not be temporary (fatal, surfaced with cooldown)") + } +} + +func TestParseServerError_RateLimitWithRetryAfter(t *testing.T) { + cases := []struct { + code string + sentinel error + scope RateLimitScope + temporary bool + }{ + {"TooManyRequests", ErrTooManyRequests, RateLimitScopeGeneral, true}, + {"TooManyRequestsRPM", ErrTooManyRequestsRPM, RateLimitScopeRPM, true}, + {"TooManyRequestsRPH", ErrTooManyRequestsRPH, RateLimitScopeRPH, false}, + {"TooManyRequestsRPD", ErrTooManyRequestsRPD, RateLimitScopeRPD, false}, + } + for _, c := range cases { + hdr := http.Header{} + hdr.Set("Retry-After", "42") + body := []byte(`{"status":"error","code":"` + c.code + `"}`) + + err := parseServerError(http.StatusTooManyRequests, hdr, body) + + if !errors.Is(err, c.sentinel) { + t.Fatalf("%s: errors.Is(sentinel) failed for %v", c.code, err) + } + var rle *RateLimitError + if !errors.As(err, &rle) { + t.Fatalf("%s: errors.As(*RateLimitError) failed for %v", c.code, err) + } + if rle.Scope != c.scope { + t.Fatalf("%s: scope = %q, want %q", c.code, rle.Scope, c.scope) + } + if rle.RetryAfter != 42*time.Second { + t.Fatalf("%s: retry-after = %s, want 42s", c.code, rle.RetryAfter) + } + if isTemporaryError(err) != c.temporary { + t.Fatalf("%s: isTemporaryError = %v, want %v", c.code, isTemporaryError(err), c.temporary) + } + } +} + +func TestRateLimitError_NoHeader(t *testing.T) { + // Without a Retry-After header the wrapper still classifies correctly. + err := parseServerError(http.StatusTooManyRequests, nil, []byte(`{"code":"TooManyRequestsRPM"}`)) + var rle *RateLimitError + if !errors.As(err, &rle) || rle.RetryAfter != 0 { + t.Fatalf("expected *RateLimitError with zero RetryAfter, got %v", err) + } + if !isTemporaryError(err) { + t.Fatalf("RPM should be temporary") + } +} + +func TestCheck_EmptyConfigs(t *testing.T) { + results, err := Check(context.Background(), nil) + if err != nil { + t.Fatalf("expected no top-level error for empty configs, got: %v", err) + } + if len(results) != 0 { + t.Errorf("expected empty results, got %d entries", len(results)) + } +} + +func TestCheck_InvalidConfigs(t *testing.T) { + cases := []struct { + name string + cfg CallConfig + wantIs error + }{ + { + name: "missing host", + cfg: CallConfig{Name: "test-endpoint"}, + wantIs: ErrInvalidConfiguration, + }, + { + name: "missing name", + cfg: CallConfig{Host: "api.example.com"}, + wantIs: ErrInvalidConfiguration, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + // key in results is always cfg.Name (may be "") + key := c.cfg.Name + + results, err := Check(context.Background(), []CallConfig{c.cfg}) + if err != nil { + t.Fatalf("unexpected top-level error: %v", err) + } + + status, ok := results[key] + if !ok { + t.Fatalf("no result for key %q (results: %v)", key, results) + } + if status.LastError() == nil { + t.Error("expected per-endpoint error, got nil") + } + if !errors.Is(status.LastError(), c.wantIs) { + t.Errorf("errors.Is(%v) failed; got: %v", c.wantIs, status.LastError()) + } + // IsReachable works via pointer receiver + if status.IsReachable() { + t.Error("expected IsReachable=false for an invalid config") + } + }) + } +} + +func TestRetryAfterOf(t *testing.T) { + tests := []struct { + name string + err error + want time.Duration + }{ + // no hint: returns 0 + {"nil", nil, 0}, + {"plain ErrForbidden", ErrForbidden, 0}, + {"plain ErrBadRequest", ErrBadRequest, 0}, + {"plain ErrTooManyRequestsRPM bare sentinel", ErrTooManyRequestsRPM, 0}, + {"QuotaBlocked — never retry", &QuotaError{ + Err: ErrQuotaBlocked, Scope: QuotaScopeBlocked}, 0, + }, + {"RateLimitError with zero RetryAfter", &RateLimitError{ + Err: ErrTooManyRequestsRPM, Scope: RateLimitScopeRPM, RetryAfter: 0}, 0, + }, + + // server hint carried in *RateLimitError + {"RPM with RetryAfter", &RateLimitError{ + Err: ErrTooManyRequestsRPM, Scope: RateLimitScopeRPM, RetryAfter: 42 * time.Second}, 42 * time.Second, + }, + {"RPH with RetryAfter", &RateLimitError{ + Err: ErrTooManyRequestsRPH, Scope: RateLimitScopeRPH, RetryAfter: 3300 * time.Second}, 3300 * time.Second, + }, + {"RPD with RetryAfter", &RateLimitError{ + Err: ErrTooManyRequestsRPD, Scope: RateLimitScopeRPD, RetryAfter: 79200 * time.Second}, 79200 * time.Second, + }, + {"general with RetryAfter", &RateLimitError{ + Err: ErrTooManyRequests, Scope: RateLimitScopeGeneral, RetryAfter: 5 * time.Second}, 5 * time.Second, + }, + + // server hint carried in *QuotaError + {"QuotaExceededDaily with RetryAfter", &QuotaError{ + Err: ErrQuotaExceededDaily, Scope: QuotaScopeDaily, RetryAfter: time.Hour}, time.Hour, + }, + {"QuotaExceededMonthly with RetryAfter", &QuotaError{ + Err: ErrQuotaExceededMonthly, Scope: QuotaScopeMonthly, RetryAfter: 720 * time.Hour}, 720 * time.Hour, + }, + + // wrapped via %w — errors.As still finds the inner type + {"wrapped RateLimitError", fmt.Errorf("request failed: %w", &RateLimitError{ + Err: ErrTooManyRequestsRPM, Scope: RateLimitScopeRPM, RetryAfter: 7 * time.Second}), 7 * time.Second, + }, + + // context deadline joined with rate-limit error (our fix in calls.go) + {"context deadline + RateLimitError", fmt.Errorf("%w: %w", context.DeadlineExceeded, &RateLimitError{ + Err: ErrTooManyRequestsRPM, Scope: RateLimitScopeRPM, RetryAfter: 55 * time.Second}), 55 * time.Second, + }, + {"context deadline alone", fmt.Errorf("%w", context.DeadlineExceeded), 0}, + + // parseServerError produces these via http.Header with Retry-After + {"parseServerError RPM", parseServerError(429, func() http.Header { + h := http.Header{} + h.Set("Retry-After", "30") + return h + }(), []byte(`{"code":"TooManyRequestsRPM"}`)), 30 * time.Second}, + {"parseServerError QuotaDaily", parseServerError(429, func() http.Header { + h := http.Header{} + h.Set("Retry-After", "3600") + return h + }(), []byte(`{"code":"QuotaExceededDaily"}`)), time.Hour}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := RetryAfterOf(tt.err) + if got != tt.want { + t.Errorf("RetryAfterOf() = %v, want %v (err = %v)", got, tt.want, tt.err) + } + }) + } +} + +func TestRetryAfterOf_UsagePattern(t *testing.T) { + // Simulate what parseServerError returns for a real 429 response + h := http.Header{} + h.Set("Retry-After", "42") + err := parseServerError(429, h, []byte(`{"code":"TooManyRequestsRPM"}`)) + + // Caller does NOT need to know about *RateLimitError: + wait := RetryAfterOf(err) + if wait != 42*time.Second { + t.Fatalf("expected 42s, got %v", wait) + } + + // errors.Is classification still works normally + if !errors.Is(err, ErrTooManyRequestsRPM) { + t.Fatal("errors.Is check failed") + } +} + func TestBuildCallValidation(t *testing.T) { s := defaultSDK() diff --git a/sdk/transport.go b/sdk/transport.go index 2c90646..e66e938 100644 --- a/sdk/transport.go +++ b/sdk/transport.go @@ -13,6 +13,7 @@ import ( "io" "net" "net/http" + "net/url" "time" "github.com/google/uuid" @@ -107,6 +108,7 @@ func (c *callFunc) getTicket(cctx *callContext) (string, error) { return "", fmt.Errorf("failed to create ticket request: %w", err) } + c.sdk.applyExtraHeaders(httpReq) httpReq.Header.Set(headerXInstallationID, uuid.UUID(c.sdk.installationID).String()) httpReq.Header.Set(headerXRequestID, requestID.String()) httpReq.Header.Set(headerXRequestKey, requestKeyHeader) @@ -130,8 +132,8 @@ func (c *callFunc) getTicket(cctx *callContext) (string, error) { return "", fmt.Errorf("failed to read ticket response: %w", err) } - if resp.StatusCode != http.StatusOK { - return "", parseServerError(resp.StatusCode, body) + if !isSuccessStatus(resp.StatusCode) { + return "", parseServerError(resp.StatusCode, resp.Header, body) } decryptedBody, err := DecryptBytes(body, sessionKey, sessionIV) @@ -154,7 +156,7 @@ func (c *callFunc) solvePoW(cctx *callContext, ticket string) (*ticketData, erro xor(result.Catalyst[0:16], c.sdk.installationID[0:16]) - ticketData := &ticketData{ + td := &ticketData{ Key: [16]byte(result.Key), IV: generateIV(c.sdk.installationID), RequestID: uuid.UUID(result.Catalyst), @@ -163,13 +165,13 @@ func (c *callFunc) solvePoW(cctx *callContext, ticket string) (*ticketData, erro WaitDelay: result.Recipe.RestTime, } - return ticketData, nil + return td, nil } -func (c *callFunc) createSignature(ticketData *ticketData, contentLength int64) (string, error) { +func (c *callFunc) createSignature(td *ticketData, contentLength int64) (string, error) { // nonce[16] + randPadding[11] + version[1] + timestamp[8] + contentLength[8] + crc32[4] signData := make([]byte, 48) - copy(signData[0:16], ticketData.Nonce[0:16]) + copy(signData[0:16], td.Nonce[0:16]) xor(signData[0:16], c.sdk.installationID[0:16]) _, err := rand.Read(signData[16:27]) @@ -184,12 +186,12 @@ func (c *callFunc) createSignature(ticketData *ticketData, contentLength int64) hash := crc32.ChecksumIEEE(signData[16:44]) binary.BigEndian.PutUint32(signData[44:48], hash) - sc, err := aes.NewCipher(ticketData.Key[:]) + sc, err := aes.NewCipher(td.Key[:]) if err != nil { return "", fmt.Errorf("failed to create AES cipher: %w", err) } - scb := cipher.NewCBCEncrypter(sc, ticketData.IV[:]) + scb := cipher.NewCBCEncrypter(sc, td.IV[:]) scb.CryptBlocks(signData[16:48], signData[16:48]) return base64.StdEncoding.WithPadding(base64.NoPadding).EncodeToString(signData), nil @@ -257,50 +259,72 @@ func (c *callFunc) createUserAgentHeader() string { return userAgent } -// invokeRequest performs a complete PoW-protected request -func (c *callFunc) invokeRequest(cctx *callContext) error { - // step 1: get PoW ticket +// fetchTicketData acquires and solves a PoW ticket; shared by invokeRequest and Check. +func (c *callFunc) fetchTicketData(cctx *callContext) (*ticketData, error) { ticket, err := c.getTicket(cctx) if err != nil { - return fmt.Errorf("failed to get ticket: %w", err) + return nil, fmt.Errorf("failed to get ticket: %w", err) } - // step 2: solve PoW challenge - ticketData, err := c.solvePoW(cctx, ticket) + td, err := c.solvePoW(cctx, ticket) if err != nil { - return fmt.Errorf("failed to solve PoW: %w", err) + return nil, fmt.Errorf("failed to solve PoW: %w", err) } - // step 3: prepare encrypted request + return td, nil +} + +// newProbeContext builds a callContext with only the ticket URL set (used by Check). +func (c *callFunc) newProbeContext(ctx context.Context) *callContext { + return &callContext{ + Context: ctx, + reqTicketURL: url.URL{ + Scheme: defaultScheme, + Host: c.cfg.Host, + Path: defaultTicketPath + c.cfg.Name, + }, + } +} + +// invokeRequest performs a complete PoW-protected request +func (c *callFunc) invokeRequest(cctx *callContext) error { + // step 1: get ticket and solve PoW challenge + td, err := c.fetchTicketData(cctx) + if err != nil { + return err + } + + // step 2: prepare encrypted request var reqBody io.ReadCloser if cctx.reqBodyReader != nil && cctx.reqBodyLength > 0 { - reqBody, err = EncryptStream(cctx.reqBodyReader, ticketData.Key, ticketData.IV) + reqBody, err = EncryptStream(cctx.reqBodyReader, td.Key, td.IV) if err != nil { return fmt.Errorf("failed to encrypt request body: %w", err) } } - // step 4: create signature - signature, err := c.createSignature(ticketData, cctx.reqBodyLength) + // step 3: create signature + signature, err := c.createSignature(td, cctx.reqBodyLength) if err != nil { return fmt.Errorf("failed to create signature: %w", err) } - // step 5: make HTTP target request + // step 4: make HTTP target request httpReq, err := http.NewRequestWithContext(cctx, cctx.reqMethod, cctx.reqCallURL.String(), reqBody) if err != nil { return fmt.Errorf("failed to create request: %w", err) } + c.sdk.applyExtraHeaders(httpReq) httpReq.Header.Set(headerXInstallationID, uuid.UUID(c.sdk.installationID).String()) - httpReq.Header.Set(headerXRequestID, ticketData.RequestID.String()) + httpReq.Header.Set(headerXRequestID, td.RequestID.String()) httpReq.Header.Set(headerXRequestSign, signature) httpReq.Header.Set("User-Agent", c.createUserAgentHeader()) if cctx.reqBodyReader != nil && cctx.reqBodyLength > 0 { httpReq.Header.Set("Content-Type", "application/json") } if c.sdk.licenseKey != emptyLicenseKey && c.sdk.licenseFP != emptyLicenseFP { - licenseKeyHeader, err := c.createLicenseKeyHeader(ticketData.Key, ticketData.IV) + licenseKeyHeader, err := c.createLicenseKeyHeader(td.Key, td.IV) if err != nil { return fmt.Errorf("failed to create license key header: %w", err) } @@ -316,12 +340,12 @@ func (c *callFunc) invokeRequest(cctx *callContext) error { } cctx.respStatusCode = resp.StatusCode - if cctx.respStatusCode == http.StatusOK { + if isSuccessStatus(cctx.respStatusCode) { // response body should be closed after decryption if cctx.respBodyWriter != nil { - err = DecryptProxy(resp.Body, cctx.respBodyWriter, ticketData.Key, ticketData.IV) + err = DecryptProxy(resp.Body, cctx.respBodyWriter, td.Key, td.IV) } else { - cctx.respBodyReader, err = DecryptStream(resp.Body, ticketData.Key, ticketData.IV) + cctx.respBodyReader, err = DecryptStream(resp.Body, td.Key, td.IV) } if err != nil { return fmt.Errorf("failed to decrypt response body: %w", err) @@ -337,7 +361,7 @@ func (c *callFunc) invokeRequest(cctx *callContext) error { return fmt.Errorf("failed to read response: %w", err) } - return parseServerError(cctx.respStatusCode, responseBody) + return parseServerError(cctx.respStatusCode, resp.Header, responseBody) } func getServerPublicKey() *[32]byte { diff --git a/sdk/transport_test.go b/sdk/transport_test.go index d1bf8df..b3aa1e2 100644 --- a/sdk/transport_test.go +++ b/sdk/transport_test.go @@ -5,6 +5,7 @@ import ( "context" "crypto/tls" "errors" + "fmt" "io" "net/http" "strings" @@ -112,22 +113,22 @@ func TestTicketSuccessScenario(t *testing.T) { cctx := &callContext{Context: ctx} // test PoW solving (should complete quickly) - ticketData, err := cfn.solvePoW(cctx, successTicket.Ticket) + td, err := cfn.solvePoW(cctx, successTicket.Ticket) if err != nil { t.Errorf("Fast PoW solving failed: %v", err) return } // validate ticket data - if ticketData.Key != successTicket.key { + if td.Key != successTicket.key { t.Error("Fast PoW solving returned invalid key") return } - if ticketData.RequestID.String() != successTicket.RequestID { + if td.RequestID.String() != successTicket.RequestID { t.Error("Fast PoW solving returned invalid RequestID") return } - if ticketData.Nonce != successTicket.nonce { + if td.Nonce != successTicket.nonce { t.Error("Fast PoW solving returned invalid Nonce") return } @@ -283,8 +284,8 @@ func TestProtocolSecurity(t *testing.T) { } for _, tt := range tests { - err := parseServerError(429, []byte(tt.errorJSON)) - if err != tt.wantError { + err := parseServerError(429, nil, []byte(tt.errorJSON)) + if !errors.Is(err, tt.wantError) { t.Errorf("parseServerError() = %v, want %v", err, tt.wantError) } } @@ -735,6 +736,10 @@ func TestStreamingOperations(t *testing.T) { } func TestRetryLogic(t *testing.T) { + // Note: in production, rate-limit 429s arrive wrapped in *RateLimitError + // (via parseServerError). These test cases use bare sentinels to exercise + // the fallback switch branch in calculateWaitTime. + // See TestCalculateWaitTimeWithRetryAfter for the *RateLimitError path. tests := []struct { name string errorType error @@ -774,6 +779,214 @@ func TestRetryLogic(t *testing.T) { } } +func TestCheck_AgainstMockServer(t *testing.T) { + mockSrv := newMockServer() + server := mockSrv.createTLSServer() + defer server.Close() + + testTicket, exists := mockSrv.ticketsByName["valid_success"] + if !exists { + t.Fatal("valid_success ticket not found in test data") + } + installationID, err := uuid.Parse(testTicket.InstallationID) + if err != nil { + t.Fatalf("invalid InstallationID: %v", err) + } + + transport := DefaultTransport() + transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} + + host := strings.TrimPrefix(server.URL, "https://") + configs := []CallConfig{ + { + Host: host, + Name: "valid_success", // served by the mock ticket handler + Path: "/api/v1/test", + Method: CallMethodGET, + }, + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + results, err := Check(ctx, configs, + withServerPublicKey(mockSrv.getPublicKey()), + WithInstallationID([16]byte(installationID)), + WithPowTimeout(2*time.Second), + WithTransport(transport), + ) + if err != nil { + t.Fatalf("Check() top-level error: %v", err) + } + + status, ok := results["valid_success"] + if !ok { + t.Fatal("no result for 'valid_success'") + } + if status.LastError() != nil { + t.Errorf("expected no error, got: %v", status.LastError()) + } + if status.AllowedRPM() != 60 { + t.Errorf("expected AllowedRPM=60, got: %d", status.AllowedRPM()) + } + t.Logf("IsReachable=%v, AllowedRPM=%d", status.IsReachable(), status.AllowedRPM()) + + // Recheck updates the underlying struct through the interface; the map value reflects it. + prevRPM := status.AllowedRPM() + if err := status.Recheck(ctx); err == nil { + if results["valid_success"].AllowedRPM() != status.AllowedRPM() { + t.Error("Recheck must update the struct in place (map should reflect new value)") + } + t.Logf("After Recheck: AllowedRPM=%d (was %d)", status.AllowedRPM(), prevRPM) + } +} + +func TestCheck_ForbiddenEndpoint(t *testing.T) { + mockSrv := newMockServer() + server := mockSrv.createTLSServer() + defer server.Close() + + testTicket, exists := mockSrv.ticketsByName["valid_success"] + if !exists { + t.Fatal("valid_success ticket not found in test data") + } + installationID, err := uuid.Parse(testTicket.InstallationID) + if err != nil { + t.Fatalf("invalid InstallationID: %v", err) + } + + transport := DefaultTransport() + transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} + + host := strings.TrimPrefix(server.URL, "https://") + configs := []CallConfig{ + { + Host: host, + Name: "nonexistent_endpoint", // no ticket handler → server returns 400/403 + Path: "/api/v1/test", + Method: CallMethodGET, + }, + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + results, err := Check(ctx, configs, + withServerPublicKey(mockSrv.getPublicKey()), + WithInstallationID([16]byte(installationID)), + WithPowTimeout(1*time.Second), + WithTransport(transport), + ) + if err != nil { + t.Fatalf("unexpected top-level error: %v", err) + } + + status, ok := results["nonexistent_endpoint"] + if !ok { + t.Fatal("no result for 'nonexistent_endpoint'") + } + if status.LastError() == nil { + t.Error("expected per-endpoint error for nonexistent endpoint") + } else if !errors.Is(status.LastError(), ErrNotFound) { + t.Errorf("expected ErrNotFound, got: %v", status.LastError()) + } +} + +// TestContextCancellationPreservesRateLimitError verifies that context cancellation +// during back-off returns a joined error containing both context.DeadlineExceeded +// and the last *RateLimitError so callers can still read RetryAfter. +func TestContextCancellationPreservesRateLimitError(t *testing.T) { + rpmErr := &RateLimitError{Err: ErrTooManyRequestsRPM, Scope: RateLimitScopeRPM, RetryAfter: 55 * time.Second} + joined := fmt.Errorf("%w: %w", context.DeadlineExceeded, rpmErr) + + // context error is still detectable + if !errors.Is(joined, context.DeadlineExceeded) { + t.Fatal("expected context.DeadlineExceeded to be detectable") + } + + // rate limit error is still detectable + if !errors.Is(joined, ErrTooManyRequestsRPM) { + t.Fatal("expected ErrTooManyRequestsRPM to be detectable via errors.Is") + } + + // RetryAfter is still accessible + var rle *RateLimitError + if !errors.As(joined, &rle) { + t.Fatal("expected *RateLimitError to be extractable via errors.As") + } + if rle.RetryAfter != 55*time.Second { + t.Fatalf("expected RetryAfter=55s, got %v", rle.RetryAfter) + } + if rle.Scope != RateLimitScopeRPM { + t.Fatalf("expected Scope=%q, got %q", RateLimitScopeRPM, rle.Scope) + } + + // Caller pattern that should now work after the fix + retryIn := rle.RetryAfter + if retryIn != 55*time.Second { + t.Errorf("caller cannot determine retry wait: %v", retryIn) + } +} + +// TestCalculateWaitTimeWithRetryAfter verifies that server-advertised RetryAfter +// is used as-is (capped at DefaultWaitTime) instead of fixed fallback delays. +func TestCalculateWaitTimeWithRetryAfter(t *testing.T) { + cfn := &callFunc{} + + tests := []struct { + name string + err error + want time.Duration + }{ + { + // Server-advertised delay below DefaultWaitTime: used as-is + name: "rpm_retry_after_7s_used_as_is", + err: &RateLimitError{Err: ErrTooManyRequestsRPM, Scope: RateLimitScopeRPM, RetryAfter: 7 * time.Second}, + want: 7 * time.Second, + }, + { + // General rate limit with server delay: used as-is + name: "general_retry_after_3s", + err: &RateLimitError{Err: ErrTooManyRequests, Scope: RateLimitScopeGeneral, RetryAfter: 3 * time.Second}, + want: 3 * time.Second, + }, + { + // Server-advertised delay exceeds DefaultWaitTime: capped at DefaultWaitTime + name: "rpm_retry_after_42s_capped_at_default", + err: &RateLimitError{Err: ErrTooManyRequestsRPM, Scope: RateLimitScopeRPM, RetryAfter: 42 * time.Second}, + want: DefaultWaitTime, + }, + { + // RetryAfter == 0 (no header from server): falls through to switch → DefaultWaitTime + name: "rpm_zero_retry_after_falls_to_switch", + err: &RateLimitError{Err: ErrTooManyRequestsRPM, Scope: RateLimitScopeRPM, RetryAfter: 0}, + want: DefaultWaitTime, + }, + { + // RetryAfter exactly at DefaultWaitTime: not capped + name: "rpm_retry_after_exactly_default", + err: &RateLimitError{Err: ErrTooManyRequestsRPM, Scope: RateLimitScopeRPM, RetryAfter: DefaultWaitTime}, + want: DefaultWaitTime, + }, + { + // RPH with small RetryAfter (hypothetical, RPH is non-retryable but + // calculateWaitTime still returns sensible values if somehow called) + name: "rph_retry_after_5s_used_as_is", + err: &RateLimitError{Err: ErrTooManyRequestsRPH, Scope: RateLimitScopeRPH, RetryAfter: 5 * time.Second}, + want: 5 * time.Second, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := cfn.calculateWaitTime(tt.err, nil) + if got != tt.want { + t.Errorf("calculateWaitTime() = %v, want %v", got, tt.want) + } + }) + } +} + func TestErrorScenarios(t *testing.T) { mockSrv := newMockServer() server := mockSrv.createTLSServer()