package main import ( "encoding/json" "fmt" "strings" ) // SessionResponse from /auth/skill-credit/session type SessionResponse struct { AccessToken string `json:"accessToken"` OwnerSessionToken string `json:"ownerSessionToken,omitempty"` HookURL string `json:"hookUrl,omitempty"` HookToken string `json:"hookToken,omitempty"` ExpiresIn int `json:"expiresIn"` ClientID string `json:"clientId,omitempty"` OwnerUserID string `json:"ownerUserId,omitempty"` Balance int `json:"balance,omitempty"` } // ClientConfigResponse from /auth/skill-credit/client-config type ClientConfigResponse struct { ClientID string `json:"clientId"` Name string `json:"name"` Status string `json:"status"` Metadata map[string]interface{} `json:"metadata"` } var retryableStatuses = map[int]bool{401: true, 403: true} var retryableMarkers = []string{ "session not found or expired", "invalid or expired token", "unauthorized", "client key expired", "client key revoked", } func isRetryable(resp APIResponse) bool { if !retryableStatuses[resp.Status] { return false } body := strings.ToLower(resp.Body) if body == "" { return true } for _, m := range retryableMarkers { if strings.Contains(body, m) { return true } } return false } // fetchSession exchanges CLIENT_KEY for a session token. func fetchSession(cfg Config) (*SessionResponse, error) { if cfg.ClientKey == "" { return nil, fmt.Errorf("CLIENT_KEY is required") } payload := fmt.Sprintf(`{"clientKey":"%s"}`, cfg.ClientKey) resp, err := doHTTP("POST", cfg.AuthBase+"/auth/skill-credit/session", "", payload, nil) if err != nil { return nil, fmt.Errorf("session request failed: %w", err) } if resp.Status < 200 || resp.Status >= 300 { return nil, fmt.Errorf("auth session failed: HTTP %d — %s", resp.Status, resp.Body) } var session SessionResponse if err := json.Unmarshal([]byte(resp.Body), &session); err != nil { return nil, fmt.Errorf("invalid session JSON: %w", err) } if session.AccessToken == "" { return nil, fmt.Errorf("missing accessToken in session response: %s", resp.Body) } return &session, nil } // fetchClientConfig retrieves client metadata. func fetchClientConfig(cfg Config) (*ClientConfigResponse, error) { if cfg.ClientKey == "" { return nil, fmt.Errorf("CLIENT_KEY is required") } headers := map[string]string{"X-Client-Key": cfg.ClientKey} resp, err := doHTTP("GET", cfg.AuthBase+"/auth/skill-credit/client-config", "", "", headers) if err != nil { return nil, fmt.Errorf("client-config request failed: %w", err) } if resp.Status < 200 || resp.Status >= 300 { return nil, fmt.Errorf("client-config failed: HTTP %d — %s", resp.Status, resp.Body) } var config ClientConfigResponse if err := json.Unmarshal([]byte(resp.Body), &config); err != nil { return nil, fmt.Errorf("invalid client-config JSON: %w", err) } return &config, nil } // getToken returns a cached or fresh token. func getToken(cfg Config) (string, error) { cf := cacheFile(cfg.AuthBase, cfg.ClientKey, cfg.CacheDir) if cached := readCachedToken(cf, cfg.MinTTLSec); cached != "" { return cached, nil } session, err := fetchSession(cfg) if err != nil { return "", err } writeCache(cf, session.AccessToken, session.ExpiresIn) return session.AccessToken, nil } // refreshToken bypasses cache and fetches a new token. func refreshToken(cfg Config) (string, error) { cf := cacheFile(cfg.AuthBase, cfg.ClientKey, cfg.CacheDir) deleteCache(cf) session, err := fetchSession(cfg) if err != nil { return "", err } writeCache(cf, session.AccessToken, session.ExpiresIn) return session.AccessToken, nil } // requestWithRetry implements the 3-tier retry logic: // 1. cached/fresh token → try // 2. refresh token with same key → retry // 3. reload .env (maybe CLIENT_KEY changed) → refresh → retry func requestWithRetry(cfg Config, method, path, body string) (*APIResponse, error) { url := cfg.APIBase + path // 1. Try with cached or fresh token token, err := getToken(cfg) if err != nil { return nil, err } first, err := doHTTP(method, url, token, body, nil) if err != nil { return nil, err } if !isRetryable(first) { return &first, nil } // 2. Refresh token with same key freshToken, err := refreshToken(cfg) if err != nil { return nil, err } second, err := doHTTP(method, url, freshToken, body, nil) if err != nil { return nil, err } if !isRetryable(second) { return &second, nil } // 3. Reload .env — CLIENT_KEY may have changed oldKey := cfg.ClientKey cfg = reloadConfig(cfg) if cfg.ClientKey != oldKey { oldCF := cacheFile(cfg.AuthBase, oldKey, cfg.CacheDir) deleteCache(oldCF) } reloadedToken, err := refreshToken(cfg) if err != nil { return nil, err } third, err := doHTTP(method, url, reloadedToken, body, nil) if err != nil { return nil, err } return &third, nil }