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()