feat(api): error codes, idempotency keys, cursor pagination

- Stable machine-readable error codes alongside the error message
  (UNAUTHENTICATED, INVALID_TOKEN, ADMIN_REQUIRED, SCOPE_INSUFFICIENT,
  IDEMPOTENCY_IN_PROGRESS); CLI renders "CODE: message".
- Idempotency middleware on authed/admin groups: POST requests with an
  Idempotency-Key header replay the first response for 24h (Redis-backed,
  caller-scoped); concurrent duplicates get 409. X-Idempotent-Replay
  header marks replays.
- Shared pagination helpers: ?cursor= (opaque, preferred) alongside
  legacy ?page= on projects and deployments lists; pagination meta
  includes next_cursor; invalid cursors fail soft to page 1.
- CLI --idempotency-key flag for safe scripted retries.
- OpenAPI updated: error envelope, cursor params, idempotency contract.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-10-08 15:38:36 +02:00
1 parent 485e41a817
commit 18f78f00a3
15 files changed
+454 -56

No files matched your search

@@ -8,6 +8,8 @@ import (
"net/http"
"strings"
"time"
"github.com/spf13/viper"
)
// Exit codes — stable for scripting and agent use.
@@ -109,6 +111,9 @@ func (c *Client) Do(method, path string, body interface{}) ([]byte, error) {
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if k := viper.GetString("idempotency-key"); k != "" && method == http.MethodPost {
req.Header.Set("Idempotency-Key", k)
}
req.Header.Set("Accept", "application/json")
hc := c.HTTPClient
@@ -134,9 +139,13 @@ func (c *Client) Do(method, path string, body interface{}) ([]byte, error) {
msg := strings.TrimSpace(string(data))
var envelope struct {
Error string `json:"error"`
Code string `json:"code"`
}
if json.Unmarshal(data, &envelope) == nil && envelope.Error != "" {
msg = envelope.Error
if envelope.Code != "" {
msg = envelope.Code + ": " + msg
}
}
return nil, &APIError{
Status: resp.StatusCode,
+2
View File
@@ -55,12 +55,14 @@ func init() {
pf.StringP("profile", "p", "", "config profile to use (default current_profile)")
pf.Bool("json", false, "machine-readable JSON output")
pf.BoolP("yes", "y", false, "skip confirmation prompts (required for destructive ops in scripts)")
pf.String("idempotency-key", "", "Idempotency-Key header for POST mutations (safe retries)")
viper.BindPFlag("api-url", pf.Lookup("api-url"))
viper.BindPFlag("token", pf.Lookup("token"))
viper.BindPFlag("profile", pf.Lookup("profile"))
viper.BindPFlag("json", pf.Lookup("json"))
viper.BindPFlag("yes", pf.Lookup("yes"))
viper.BindPFlag("idempotency-key", pf.Lookup("idempotency-key"))
rootCmd.AddCommand(commands.AuthCmd)
rootCmd.AddCommand(commands.TokensCmd)