Files
attesto-go/client.go
Codex 180bec4643 sdk+backend(P1.9): protocol-version handshake on every request
All three SDKs now send X-Attesto-Sdk (attesto-<lang>/<version>) and
X-Attesto-Protocol (ATTESTO-PROOFSTREAM-001/0.1-alpha) on every request. A new
backend ProtocolVersionMiddleware logs both headers (operators can see the
SDK/protocol mix in traffic) and, when the protocol header is present on a /v2
request and names a different protocol identifier or major version, answers
426 Upgrade Required with a structured body (error/supported/received/hint).
Absent or unparseable headers change nothing — old clients and curl stay fully
compatible (test-asserted, including /v1 never being handshake-gated).

SDKs surface the 426 as a typed error: Python AttestoProtocolMismatch,
TypeScript AttestoProtocolMismatch, Go IsProtocolMismatch(err) over *APIError
(Go-idiomatic). Tests cover the mismatch rules, the 426 mapping, and that the
handshake headers are actually sent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 19:24:20 +02:00

669 lines
23 KiB
Go

package attesto
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
)
type Client struct {
baseURL string
bearer string
httpClient *http.Client
maxRetries int
userAgent string
validateKey bool
headStore HeadStore
}
type Option func(*Client) error
var apiKeyPattern = regexp.MustCompile(`^atto_(?:live|test)_[0-9a-f]{32}$`)
func NewClient(apiKey string, opts ...Option) (*Client, error) {
if !apiKeyPattern.MatchString(apiKey) {
return nil, errors.New("api key must match atto_live_<32 lowercase hex chars> or atto_test_<32 lowercase hex chars>")
}
return newClient(apiKey, true, opts...)
}
func NewBearerClient(token string, opts ...Option) (*Client, error) {
if strings.TrimSpace(token) == "" {
return nil, errors.New("bearer token must not be blank")
}
return newClient(token, false, opts...)
}
func newClient(bearer string, validateKey bool, opts ...Option) (*Client, error) {
client := &Client{
baseURL: DefaultBaseURL,
bearer: bearer,
httpClient: &http.Client{Timeout: 10 * time.Second},
maxRetries: 3,
userAgent: "attesto-go/" + SDKVersion,
validateKey: validateKey,
headStore: NewMemoryHeadStore(),
}
for _, opt := range opts {
if err := opt(client); err != nil {
return nil, err
}
}
if _, err := url.ParseRequestURI(client.baseURL); err != nil {
return nil, fmt.Errorf("base url must be an absolute URL: %w", err)
}
client.baseURL = strings.TrimRight(client.baseURL, "/")
return client, nil
}
func WithBaseURL(baseURL string) Option {
return func(c *Client) error {
baseURL = strings.TrimSpace(strings.TrimRight(baseURL, "/"))
if baseURL == "" {
return errors.New("base url must not be blank")
}
parsed, err := url.Parse(baseURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return errors.New("base url must be an absolute URL")
}
if parsed.Scheme != "https" && parsed.Scheme != "http" {
return errors.New("base url must use http or https")
}
c.baseURL = baseURL
return nil
}
}
func WithHTTPClient(httpClient *http.Client) Option {
return func(c *Client) error {
if httpClient == nil {
return errors.New("http client must not be nil")
}
c.httpClient = httpClient
return nil
}
}
func WithMaxRetries(maxRetries int) Option {
return func(c *Client) error {
if maxRetries < 1 {
return errors.New("max retries must be at least 1")
}
c.maxRetries = maxRetries
return nil
}
}
func WithUserAgent(userAgent string) Option {
return func(c *Client) error {
if strings.TrimSpace(userAgent) == "" {
return errors.New("user agent must not be blank")
}
c.userAgent = userAgent
return nil
}
}
// WithHeadStore sets the store used for client-side fork detection. The default
// is an in-memory store; pass NewFileHeadStore(path) to persist across process
// invocations, or nil to disable fork detection.
func WithHeadStore(store HeadStore) Option {
return func(c *Client) error {
c.headStore = store
return nil
}
}
func (c *Client) CreateStream(ctx context.Context, input StreamCreateInput, options ...RequestOptions) (*Stream, error) {
var out Stream
if input.Metadata == nil {
input.Metadata = M{}
}
err := c.requestJSON(ctx, http.MethodPost, "/v2/streams", nil, input, idempotency(options), &out)
return &out, err
}
func (c *Client) GetStreamHead(ctx context.Context, streamID string) (*StreamHead, error) {
var out StreamHead
err := c.requestJSON(ctx, http.MethodGet, "/v2/streams/"+url.PathEscape(streamID)+"/head", nil, nil, "", &out)
return &out, err
}
func (c *Client) ListTenantStreams(ctx context.Context, systemID string, limit, offset int) ([]TenantStream, error) {
values := url.Values{}
if systemID != "" {
values.Set("systemId", systemID)
}
setPaging(values, limit, offset)
var out []TenantStream
err := c.requestJSON(ctx, http.MethodGet, "/v2/tenant/streams", values, nil, "", &out)
return out, err
}
func (c *Client) GetTenantStream(ctx context.Context, streamID string) (*TenantStream, error) {
offset := 0
for {
streams, err := c.ListTenantStreams(ctx, "", 500, offset)
if err != nil {
return nil, err
}
for _, stream := range streams {
if stream.StreamID == streamID {
return &stream, nil
}
}
if len(streams) < 500 {
return nil, fmt.Errorf("stream not found: %s", streamID)
}
offset += 500
}
}
func (c *Client) LogEvent(ctx context.Context, streamID string, input EventInput, options ...RequestOptions) (*EventReceipt, error) {
if err := normalizeEventInput(&input); err != nil {
return nil, err
}
// Reject commitment-unsafe numbers locally so the developer sees the rule at
// dev time rather than as a production 422; SkipPreflight defers to the server.
if !skipPreflight(options) {
if err := AssertCommitmentSafeNumbers(input.Payload, "$.payload"); err != nil {
return nil, err
}
if err := AssertCommitmentSafeNumbers(input.Metadata, "$.metadata"); err != nil {
return nil, err
}
}
var out EventReceipt
if err := c.requestJSON(ctx, http.MethodPost, "/v2/streams/"+url.PathEscape(streamID)+"/events", nil, input, idempotency(options), &out); err != nil {
return nil, err
}
if err := c.trackHead(out); err != nil {
return nil, err
}
return &out, nil
}
// trackHead checks the receipt extends the stored head (returning a
// *ForkDetectedError on a rewind/divergence) and advances the store. No-op when
// fork detection is disabled (headStore is nil).
func (c *Client) trackHead(receipt EventReceipt) error {
if c.headStore == nil {
return nil
}
return checkAndAdvanceHead(c.headStore, receipt)
}
func (c *Client) LogEvents(ctx context.Context, streamID string, events []EventInput, options ...RequestOptions) (*EventBatchResponse, error) {
if len(events) > 1000 {
return nil, errors.New("max 1000 events per batch")
}
preflight := !skipPreflight(options)
for i := range events {
if err := normalizeEventInput(&events[i]); err != nil {
return nil, fmt.Errorf("event %d: %w", i, err)
}
if preflight {
if err := AssertCommitmentSafeNumbers(events[i].Payload, fmt.Sprintf("$.events[%d].payload", i)); err != nil {
return nil, err
}
if err := AssertCommitmentSafeNumbers(events[i].Metadata, fmt.Sprintf("$.events[%d].metadata", i)); err != nil {
return nil, err
}
}
}
body := M{"events": events}
var out EventBatchResponse
if err := c.requestJSON(ctx, http.MethodPost, "/v2/streams/"+url.PathEscape(streamID)+"/events/batch", nil, body, idempotency(options), &out); err != nil {
return nil, err
}
for _, receipt := range out.Receipts {
if err := c.trackHead(receipt); err != nil {
return nil, err
}
}
return &out, nil
}
func (c *Client) GetReceipt(ctx context.Context, streamEventID string) (*EventReceipt, error) {
var out EventReceipt
err := c.requestJSON(ctx, http.MethodGet, "/v2/receipts/"+url.PathEscape(streamEventID), nil, nil, "", &out)
return &out, err
}
func (c *Client) GetWindow(ctx context.Context, windowID string) (M, error) {
return c.getObject(ctx, "/v2/windows/"+url.PathEscape(windowID), nil)
}
func (c *Client) GetCheckpoint(ctx context.Context, checkpointID string) (M, error) {
return c.getObject(ctx, "/v2/checkpoints/"+url.PathEscape(checkpointID), nil)
}
func (c *Client) GetCheckpointConsistency(ctx context.Context, checkpointID, fromCheckpointID string) (M, error) {
values := url.Values{"from": []string{fromCheckpointID}}
return c.getObject(ctx, "/v2/checkpoints/"+url.PathEscape(checkpointID)+"/consistency", values)
}
func (c *Client) GetWitnessPolicy(ctx context.Context, policyID string) (*WitnessPolicy, error) {
var out WitnessPolicy
err := c.requestJSON(ctx, http.MethodGet, "/v2/witness/policies/"+url.PathEscape(policyID), nil, nil, "", &out)
return &out, err
}
func (c *Client) GetAnchorEpoch(ctx context.Context, anchorEpochID string) (M, error) {
return c.getObject(ctx, "/v2/anchors/"+url.PathEscape(anchorEpochID), nil)
}
func (c *Client) GetIVCEpoch(ctx context.Context, ivcEpochID string) (M, error) {
return c.getObject(ctx, "/v2/ivc/epochs/"+url.PathEscape(ivcEpochID), nil)
}
func (c *Client) BuildVerifierBundle(ctx context.Context, fromCheckpointID, toCheckpointID string, options ...RequestOptions) (*VerifierBundle, error) {
body := M{"fromCheckpointId": fromCheckpointID, "toCheckpointId": toCheckpointID}
var out VerifierBundle
err := c.requestJSON(ctx, http.MethodPost, "/v2/audit/packs", nil, body, idempotency(options), &out)
return &out, err
}
func (c *Client) VerifyReceiptRemote(ctx context.Context, input ReceiptVerifyInput, options ...RequestOptions) (*VerifyReport, error) {
var out VerifyReport
err := c.requestJSON(ctx, http.MethodPost, "/v2/verify/receipt", nil, input, idempotency(options), &out)
return &out, err
}
func (c *Client) VerifyObjectRemote(ctx context.Context, input OfflineVerifyInput, options ...RequestOptions) (*VerifyReport, error) {
var out VerifyReport
err := c.requestJSON(ctx, http.MethodPost, "/v2/verify", nil, input, idempotency(options), &out)
return &out, err
}
func (c *Client) ListTenantStreamEvents(ctx context.Context, streamID string, limit, offset int) ([]M, error) {
return c.getList(ctx, "/v2/tenant/streams/"+url.PathEscape(streamID)+"/events", limit, offset)
}
func (c *Client) ListTenantWindows(ctx context.Context, streamID string, limit, offset int) ([]M, error) {
return c.getList(ctx, "/v2/tenant/streams/"+url.PathEscape(streamID)+"/windows", limit, offset)
}
func (c *Client) ListTenantCheckpoints(ctx context.Context, streamID string, limit, offset int) ([]M, error) {
return c.getList(ctx, "/v2/tenant/streams/"+url.PathEscape(streamID)+"/checkpoints", limit, offset)
}
func (c *Client) ListForkEvidence(ctx context.Context, streamID string, limit, offset int) ([]M, error) {
return c.getList(ctx, "/v2/tenant/streams/"+url.PathEscape(streamID)+"/forks", limit, offset)
}
func (c *Client) GetTenantProofState(ctx context.Context, streamID string) (M, error) {
return c.getObject(ctx, "/v2/tenant/streams/"+url.PathEscape(streamID)+"/proof-state", nil)
}
func (c *Client) ListTenantIVCEpochs(ctx context.Context, streamID string, limit, offset int) ([]M, error) {
return c.getList(ctx, "/v2/tenant/streams/"+url.PathEscape(streamID)+"/ivc/epochs", limit, offset)
}
func (c *Client) BuildTenantAuditPack(ctx context.Context, fromCheckpointID, toCheckpointID string, options ...RequestOptions) (*VerifierBundle, error) {
body := M{"fromCheckpointId": fromCheckpointID, "toCheckpointId": toCheckpointID}
var out VerifierBundle
err := c.requestJSON(ctx, http.MethodPost, "/v2/tenant/audit/packs", nil, body, idempotency(options), &out)
return &out, err
}
func (c *Client) ListSignedWebhookConnectors(ctx context.Context, limit, offset int) ([]Connector, error) {
return c.listConnectors(ctx, "/v2/tenant/connectors/signed-webhooks", limit, offset)
}
func (c *Client) CreateSignedWebhookConnector(ctx context.Context, input ConnectorCreateInput, options ...RequestOptions) (*Connector, error) {
var out Connector
err := c.requestJSON(ctx, http.MethodPost, "/v2/tenant/connectors/signed-webhooks", nil, input, idempotency(options), &out)
return &out, err
}
func (c *Client) RevokeSignedWebhookConnector(ctx context.Context, connectorID string) error {
return c.requestNoBody(ctx, http.MethodDelete, "/v2/tenant/connectors/signed-webhooks/"+url.PathEscape(connectorID))
}
func (c *Client) ListS3ObjectConnectors(ctx context.Context, limit, offset int) ([]Connector, error) {
return c.listConnectors(ctx, "/v2/tenant/connectors/s3-objects", limit, offset)
}
func (c *Client) CreateS3ObjectConnector(ctx context.Context, input S3ConnectorCreateInput, options ...RequestOptions) (*Connector, error) {
var out Connector
err := c.requestJSON(ctx, http.MethodPost, "/v2/tenant/connectors/s3-objects", nil, input, idempotency(options), &out)
return &out, err
}
func (c *Client) CommitS3Object(ctx context.Context, connectorID string, body M, options ...RequestOptions) (*EventReceipt, error) {
var out EventReceipt
err := c.requestJSON(ctx, http.MethodPost, "/v2/tenant/connectors/s3-objects/"+url.PathEscape(connectorID)+"/commit", nil, body, idempotency(options), &out)
return &out, err
}
func (c *Client) RevokeS3ObjectConnector(ctx context.Context, connectorID string) error {
return c.requestNoBody(ctx, http.MethodDelete, "/v2/tenant/connectors/s3-objects/"+url.PathEscape(connectorID))
}
func (c *Client) ListRepositoryWebhookConnectors(ctx context.Context, limit, offset int) ([]Connector, error) {
return c.listConnectors(ctx, "/v2/tenant/connectors/repository-webhooks", limit, offset)
}
func (c *Client) CreateRepositoryWebhookConnector(ctx context.Context, input RepositoryConnectorCreateInput, options ...RequestOptions) (*Connector, error) {
var out Connector
err := c.requestJSON(ctx, http.MethodPost, "/v2/tenant/connectors/repository-webhooks", nil, input, idempotency(options), &out)
return &out, err
}
func (c *Client) RevokeRepositoryWebhookConnector(ctx context.Context, connectorID string) error {
return c.requestNoBody(ctx, http.MethodDelete, "/v2/tenant/connectors/repository-webhooks/"+url.PathEscape(connectorID))
}
func (c *Client) IngestSignedWebhookEvent(ctx context.Context, connectorID string, event EventInput, secret string) (*EventReceipt, error) {
if err := normalizeEventInput(&event); err != nil {
return nil, err
}
raw, err := json.Marshal(event)
if err != nil {
return nil, err
}
headers := SignedConnectorWebhookHeaders(secret, raw, 0)
var out EventReceipt
err = c.requestRaw(ctx, http.MethodPost, "/v2/connectors/signed-webhooks/"+url.PathEscape(connectorID)+"/events", nil, raw, headers, "", &out)
return &out, err
}
func normalizeEventInput(input *EventInput) error {
if input.SourceRef == "" {
return errors.New("source ref must not be blank")
}
if input.EventType == "" {
input.EventType = "inference"
}
if input.SourceKind == "" {
input.SourceKind = "sdk"
}
if input.OccurredAt == "" {
input.OccurredAt = time.Now().UTC().Format(time.RFC3339Nano)
} else if _, err := time.Parse(time.RFC3339, input.OccurredAt); err != nil {
if _, nanoErr := time.Parse(time.RFC3339Nano, input.OccurredAt); nanoErr != nil {
return errors.New("occurredAt must include a valid RFC3339 timezone offset")
}
}
if input.Payload == nil {
input.Payload = M{}
}
if input.Metadata == nil {
input.Metadata = M{}
}
return nil
}
func (c *Client) IngestRepositoryWebhookEvent(ctx context.Context, connectorID string, rawBody []byte, headers map[string]string) (*EventReceipt, error) {
var out EventReceipt
err := c.requestRaw(ctx, http.MethodPost, "/v2/connectors/repository-webhooks/"+url.PathEscape(connectorID)+"/events", nil, rawBody, headers, "", &out)
return &out, err
}
func (c *Client) ListLocalVaultInstallations(ctx context.Context, limit, offset int) ([]LocalVaultInstallation, error) {
values := url.Values{}
setPaging(values, limit, offset)
var out []LocalVaultInstallation
err := c.requestJSON(ctx, http.MethodGet, "/v2/tenant/local-vault/installations", values, nil, "", &out)
return out, err
}
func (c *Client) CreateLocalVaultInstallation(ctx context.Context, input LocalVaultInstallationCreateInput, options ...RequestOptions) (*LocalVaultInstallation, error) {
var out LocalVaultInstallation
err := c.requestJSON(ctx, http.MethodPost, "/v2/tenant/local-vault/installations", nil, input, idempotency(options), &out)
return &out, err
}
func (c *Client) RevokeLocalVaultInstallation(ctx context.Context, installationID string) error {
return c.requestNoBody(ctx, http.MethodDelete, "/v2/tenant/local-vault/installations/"+url.PathEscape(installationID))
}
func (c *Client) RelayLocalVaultEvent(ctx context.Context, installationID string, envelope M, payload M, envelopeHash, signatureHex, publicKeyHex string) (*EventReceipt, error) {
body := M{"envelope": envelope, "payload": payload}
headers := map[string]string{
"X-Attesto-Local-Vault-Envelope-Hash": envelopeHash,
"X-Attesto-Local-Vault-Signature": signatureHex,
"X-Attesto-Local-Vault-Public-Key": publicKeyHex,
}
var out EventReceipt
err := c.requestRaw(ctx, http.MethodPost, "/v2/local-vault/installations/"+url.PathEscape(installationID)+"/events", nil, mustJSON(body), headers, "", &out)
return &out, err
}
func (c *Client) SubmitLocalVaultWitnessReceipt(ctx context.Context, installationID string, receipt M, options ...RequestOptions) (M, error) {
return c.postObject(ctx, "/v2/local-vault/installations/"+url.PathEscape(installationID)+"/witness/checkpoints", M{"receipt": receipt}, idempotency(options))
}
func (c *Client) SubmitLocalVaultForkEvidence(ctx context.Context, installationID string, forkEvidence M, options ...RequestOptions) (M, error) {
return c.postObject(ctx, "/v2/local-vault/installations/"+url.PathEscape(installationID)+"/witness/checkpoints", M{"forkEvidence": forkEvidence}, idempotency(options))
}
func (c *Client) GetMarketplaceItem(ctx context.Context, slug string) (M, error) {
return c.getObject(ctx, "/v1/marketplace/items/"+url.PathEscape(slug), nil)
}
func (c *Client) SubmitMarketplaceAsset(ctx context.Context, input MarketplaceAssetSubmitInput, options ...RequestOptions) (M, error) {
return c.postObject(ctx, "/v1/marketplace/publisher/assets", M{
"manifest": input.Manifest,
"sourceRef": input.SourceRef,
"visibility": input.Visibility,
"pricingModel": input.PricingModel,
"priceCents": input.PriceCents,
}, idempotency(options))
}
func (c *Client) ListMarketplaceReviewAssets(ctx context.Context, state string) ([]M, error) {
values := url.Values{}
if state != "" {
values.Set("state", state)
}
var out []M
err := c.requestJSON(ctx, http.MethodGet, "/v1/platform/marketplace/assets", values, nil, "", &out)
return out, err
}
func (c *Client) ApproveMarketplaceAsset(ctx context.Context, slug string, reason string, options ...RequestOptions) (M, error) {
return c.postObject(ctx, "/v1/platform/marketplace/assets/"+url.PathEscape(slug)+"/approve", M{"reason": reason}, idempotency(options))
}
func (c *Client) getObject(ctx context.Context, path string, values url.Values) (M, error) {
var out M
err := c.requestJSON(ctx, http.MethodGet, path, values, nil, "", &out)
return out, err
}
func (c *Client) postObject(ctx context.Context, path string, body M, idempotencyKey string) (M, error) {
var out M
err := c.requestJSON(ctx, http.MethodPost, path, nil, body, idempotencyKey, &out)
return out, err
}
func (c *Client) getList(ctx context.Context, path string, limit, offset int) ([]M, error) {
values := url.Values{}
setPaging(values, limit, offset)
var out []M
err := c.requestJSON(ctx, http.MethodGet, path, values, nil, "", &out)
return out, err
}
func (c *Client) listConnectors(ctx context.Context, path string, limit, offset int) ([]Connector, error) {
values := url.Values{}
setPaging(values, limit, offset)
var out []Connector
err := c.requestJSON(ctx, http.MethodGet, path, values, nil, "", &out)
return out, err
}
func (c *Client) requestNoBody(ctx context.Context, method, path string) error {
return c.requestRaw(ctx, method, path, nil, nil, nil, "", nil)
}
func (c *Client) requestJSON(ctx context.Context, method, path string, values url.Values, body any, idempotencyKey string, out any) error {
var raw []byte
var err error
if body != nil {
raw, err = json.Marshal(body)
if err != nil {
return err
}
}
return c.requestRaw(ctx, method, path, values, raw, nil, idempotencyKey, out)
}
func (c *Client) requestRaw(ctx context.Context, method, path string, values url.Values, body []byte, extraHeaders map[string]string, idempotencyKey string, out any) error {
if values != nil && len(values) > 0 {
path += "?" + values.Encode()
}
var lastErr error
for attempt := 1; attempt <= c.maxRetries; attempt++ {
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.bearer)
req.Header.Set("User-Agent", c.userAgent)
req.Header.Set("X-Attesto-SDK", c.userAgent)
// [P1.9] protocol handshake: the backend answers 426 when it speaks a
// different protocol generation (ErrProtocolMismatch via APIError).
req.Header.Set("X-Attesto-Protocol", ProtocolHandshake)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if idempotencyKey != "" {
req.Header.Set("Idempotency-Key", idempotencyKey)
}
for key, value := range extraHeaders {
req.Header.Set(key, value)
}
resp, err := c.httpClient.Do(req)
if err != nil {
lastErr = err
if attempt == c.maxRetries {
break
}
sleep(attempt)
continue
}
err = decodeResponse(resp, out)
if err == nil {
return nil
}
lastErr = err
var apiErr *APIError
if errors.As(err, &apiErr) && !retryable(apiErr.StatusCode) {
return err
}
if attempt < c.maxRetries {
sleep(attempt)
}
}
return lastErr
}
type APIError struct {
StatusCode int
Message string
}
func (e *APIError) Error() string {
return fmt.Sprintf("attesto api error: status=%d message=%s", e.StatusCode, e.Message)
}
// IsProtocolMismatch reports whether err is a 426 protocol-handshake rejection:
// the backend speaks a different protocol generation than this SDK announced in
// X-Attesto-Protocol. Upgrade the SDK to a release that speaks it.
func IsProtocolMismatch(err error) bool {
var apiErr *APIError
return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusUpgradeRequired
}
func decodeResponse(resp *http.Response, out any) error {
defer resp.Body.Close()
if resp.StatusCode == http.StatusNoContent {
return nil
}
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return err
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
if out == nil || len(raw) == 0 {
return nil
}
return json.Unmarshal(raw, out)
}
message := http.StatusText(resp.StatusCode)
var parsed M
if json.Unmarshal(raw, &parsed) == nil {
if detail, ok := parsed["detail"].(string); ok && detail != "" {
message = detail
}
}
return &APIError{StatusCode: resp.StatusCode, Message: sanitizeMessage(message)}
}
func retryable(status int) bool {
switch status {
case http.StatusRequestTimeout, http.StatusTooManyRequests, http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout:
return true
default:
return false
}
}
func skipPreflight(options []RequestOptions) bool {
return len(options) > 0 && options[0].SkipPreflight
}
func idempotency(options []RequestOptions) string {
if len(options) > 0 && options[0].IdempotencyKey != "" {
return options[0].IdempotencyKey
}
var raw [16]byte
if _, err := rand.Read(raw[:]); err != nil {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
return hex.EncodeToString(raw[:])
}
func setPaging(values url.Values, limit, offset int) {
if limit > 0 {
values.Set("limit", fmt.Sprintf("%d", limit))
}
if offset > 0 {
values.Set("offset", fmt.Sprintf("%d", offset))
}
}
func sleep(attempt int) {
time.Sleep(time.Duration(100*attempt) * time.Millisecond)
}
func mustJSON(value any) []byte {
raw, err := json.Marshal(value)
if err != nil {
panic(err)
}
return raw
}
func sanitizeMessage(message string) string {
for _, marker := range []string{"sk_live_", "sk_test_", "pk_live_", "pk_test_", "npm_", "pypi-", "atto_live_", "atto_test_"} {
if strings.Contains(message, marker) {
return "redacted"
}
}
return message
}