refactor: update API documentation to reflect changes in cryptographic methods, error handling, and introduce endpoint health check functionality

This commit is contained in:
Dmitry Ng
2026-06-18 19:46:30 +03:00
parent 8288952770
commit a72a72600a
8 changed files with 1088 additions and 104 deletions
+85 -37
View File
@@ -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<br/>Ed25519 signed<br/>Anonymized data
Note over SDK,API: AES-GCM encrypted<br/>AES-CBC signed<br/>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: <seconds>` 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: <stable-machine-uuid>
X-Request-ID: <random-uuid>
X-Request-Key: <base64(clientPublicKey[32] + nonce[24] + encrypted(sessionKey+sessionIV+ts+len)[64])>
X-License-Key: <base64-encrypted-license> (optional)
User-Agent: MyApp/1.0.0 sdk/1.0.0
```
Main API request:
```
X-Installation-ID: <stable-machine-uuid>
X-Request-ID: <pow-derived-request-uuid>
X-Request-Sign: <base64-AES-CBC-signature[48]>
X-License-Key: <base64-encrypted-license> (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"
}
```
+169 -11
View File
@@ -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
+13 -3
View File
@@ -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
+7 -1
View File
@@ -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 {
+260 -19
View File
@@ -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")
+285 -1
View File
@@ -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()
+50 -26
View File
@@ -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 {
+219 -6
View File
@@ -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()