client.go 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
  1. // Package api wraps the ZenMux subscription-detail endpoint.
  2. package api
  3. import (
  4. "context"
  5. "encoding/json"
  6. "errors"
  7. "fmt"
  8. "io"
  9. "net/http"
  10. "net/url"
  11. "time"
  12. )
  13. // DefaultBaseURL is the production ZenMux API.
  14. const DefaultBaseURL = "https://zenmux.ai"
  15. // Response mirrors the top-level JSON envelope returned by
  16. // GET /api/v1/management/subscription/detail.
  17. type Response struct {
  18. Success bool `json:"success"`
  19. Data Data `json:"data"`
  20. Error string `json:"error,omitempty"`
  21. }
  22. // Data holds the populated fields when Success is true.
  23. type Data struct {
  24. Plan Plan `json:"plan"`
  25. Currency string `json:"currency"`
  26. BaseUSDPerFlow float64 `json:"base_usd_per_flow"`
  27. EffectiveUSDPerFlow float64 `json:"effective_usd_per_flow"`
  28. AccountStatus string `json:"account_status"`
  29. Quota5Hour QuotaWindow `json:"quota_5_hour"`
  30. Quota7Day QuotaWindow `json:"quota_7_day"`
  31. QuotaMonthly QuotaMonthly `json:"quota_monthly"`
  32. }
  33. // Plan describes the subscription tier.
  34. type Plan struct {
  35. Tier string `json:"tier"`
  36. AmountUSD float64 `json:"amount_usd"`
  37. Interval string `json:"interval"`
  38. ExpiresAt string `json:"expires_at"`
  39. }
  40. // QuotaWindow is a rolling window (5-hour or 7-day) with used/max metrics.
  41. // ResetsAt is a pointer so we can distinguish null from the zero time.
  42. type QuotaWindow struct {
  43. UsagePercentage float64 `json:"usage_percentage"`
  44. ResetsAt *string `json:"resets_at"`
  45. MaxFlows float64 `json:"max_flows"`
  46. UsedFlows float64 `json:"used_flows"`
  47. RemainingFlows float64 `json:"remaining_flows"`
  48. UsedValueUSD float64 `json:"used_value_usd"`
  49. MaxValueUSD float64 `json:"max_value_usd"`
  50. }
  51. // QuotaMonthly is the billing-cycle window. The API does not populate
  52. // used_* fields here, so we only model the max bounds.
  53. type QuotaMonthly struct {
  54. MaxFlows float64 `json:"max_flows"`
  55. MaxValueUSD float64 `json:"max_value_usd"`
  56. }
  57. // Sentinel errors for transport and status mapping.
  58. var (
  59. ErrUnauthorized = errors.New("authentication rejected")
  60. ErrRateLimited = errors.New("rate limited")
  61. ErrTimeout = errors.New("request timed out")
  62. ErrServer = errors.New("server error")
  63. ErrBadResponse = errors.New("malformed response")
  64. )
  65. // Client issues subscription-detail requests.
  66. type Client struct {
  67. BaseURL string
  68. HTTPClient *http.Client
  69. }
  70. // NewClient returns a Client with a sensible default HTTP client.
  71. func NewClient(timeout time.Duration) *Client {
  72. return &Client{
  73. BaseURL: DefaultBaseURL,
  74. HTTPClient: &http.Client{
  75. Timeout: timeout,
  76. },
  77. }
  78. }
  79. // FetchSubscriptionDetail issues a single GET request using apiKey as the
  80. // bearer token. It returns the parsed response, the raw response body
  81. // (useful for --json passthrough), and a classified error.
  82. func (c *Client) FetchSubscriptionDetail(ctx context.Context, apiKey string) (*Response, []byte, error) {
  83. endpoint, err := url.JoinPath(c.BaseURL, "/api/v1/management/subscription/detail")
  84. if err != nil {
  85. return nil, nil, fmt.Errorf("build url: %w", err)
  86. }
  87. req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
  88. if err != nil {
  89. return nil, nil, fmt.Errorf("build request: %w", err)
  90. }
  91. req.Header.Set("Authorization", "Bearer "+apiKey)
  92. req.Header.Set("Accept", "application/json")
  93. resp, err := c.HTTPClient.Do(req)
  94. if err != nil {
  95. if isTimeout(err) {
  96. return nil, nil, fmt.Errorf("%w: %v", ErrTimeout, err)
  97. }
  98. return nil, nil, err
  99. }
  100. defer resp.Body.Close()
  101. body, err := io.ReadAll(resp.Body)
  102. if err != nil {
  103. return nil, nil, fmt.Errorf("read body: %w", err)
  104. }
  105. switch resp.StatusCode {
  106. case http.StatusUnauthorized, http.StatusForbidden:
  107. return nil, body, fmt.Errorf("%w: HTTP %d", ErrUnauthorized, resp.StatusCode)
  108. case http.StatusUnprocessableEntity, http.StatusTooManyRequests:
  109. return nil, body, fmt.Errorf("%w: HTTP %d", ErrRateLimited, resp.StatusCode)
  110. }
  111. if resp.StatusCode >= 500 {
  112. return nil, body, fmt.Errorf("%w: HTTP %d", ErrServer, resp.StatusCode)
  113. }
  114. if resp.StatusCode >= 400 {
  115. return nil, body, fmt.Errorf("unexpected HTTP %d: %s", resp.StatusCode, trimBody(body))
  116. }
  117. var parsed Response
  118. if err := json.Unmarshal(body, &parsed); err != nil {
  119. return nil, body, fmt.Errorf("%w: %v", ErrBadResponse, err)
  120. }
  121. if !parsed.Success {
  122. msg := parsed.Error
  123. if msg == "" {
  124. msg = "api returned success=false"
  125. }
  126. return &parsed, body, fmt.Errorf("%w: %s", ErrBadResponse, msg)
  127. }
  128. return &parsed, body, nil
  129. }
  130. // isTimeout unwraps net/url-style errors to check for a context or client timeout.
  131. func isTimeout(err error) bool {
  132. if err == nil {
  133. return false
  134. }
  135. if errors.Is(err, context.DeadlineExceeded) {
  136. return true
  137. }
  138. var t interface{ Timeout() bool }
  139. if errors.As(err, &t) {
  140. return t.Timeout()
  141. }
  142. return false
  143. }
  144. // trimBody keeps error messages bounded.
  145. func trimBody(b []byte) string {
  146. const max = 200
  147. if len(b) <= max {
  148. return string(b)
  149. }
  150. return string(b[:max]) + "..."
  151. }