mirror of
https://github.com/vxcontrol/cloud.git
synced 2026-07-19 11:51:48 -04:00
refactor: update API documentation to reflect changes in cryptographic methods, error handling, and introduce endpoint health check functionality
This commit is contained in:
@@ -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"
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user