From ce0c924c5862f1b4b52c6291d3324b762fc47ffe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 3 Sep 2026 20:12:48 -0400 Subject: [PATCH] fix: stop replaying non-idempotent requests and expose the rotation grace A retried POST /v1/auth/rotate loses the replacement token because the presented token is already in grace, and a retried webhook registration creates duplicates. Only idempotent methods now retry. Add previous_token_grace_until to RotateApiTokenResponse and percent-encode the agent id in request paths. Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq --- agents.go | 13 +++++++------ client.go | 19 +++++++++++++++++-- types_api.go | 7 ++++--- 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/agents.go b/agents.go index 68e0609..fc66323 100644 --- a/agents.go +++ b/agents.go @@ -3,6 +3,7 @@ package elydora import ( "fmt" "net/http" + "net/url" ) // RegisterAgent registers a new AI agent within the organization. @@ -17,7 +18,7 @@ func (c *Client) RegisterAgent(req *RegisterAgentRequest) (*RegisterAgentRespons // GetAgent retrieves an agent by ID. func (c *Client) GetAgent(agentID string) (*GetAgentResponse, error) { var result GetAgentResponse - if err := c.doGet(fmt.Sprintf("/v1/agents/%s", agentID), &result); err != nil { + if err := c.doGet(fmt.Sprintf("/v1/agents/%s", url.PathEscape(agentID)), &result); err != nil { return nil, err } return &result, nil @@ -26,7 +27,7 @@ func (c *Client) GetAgent(agentID string) (*GetAgentResponse, error) { // FreezeAgent freezes an agent, preventing it from submitting operations. func (c *Client) FreezeAgent(agentID, reason string) (*FreezeAgentResponse, error) { var result FreezeAgentResponse - if err := c.doPost(fmt.Sprintf("/v1/agents/%s/freeze", agentID), &FreezeAgentRequest{ + if err := c.doPost(fmt.Sprintf("/v1/agents/%s/freeze", url.PathEscape(agentID)), &FreezeAgentRequest{ Reason: reason, }, &result); err != nil { return nil, err @@ -36,7 +37,7 @@ func (c *Client) FreezeAgent(agentID, reason string) (*FreezeAgentResponse, erro // RevokeKey revokes a specific key for an agent. func (c *Client) RevokeKey(agentID, kid, reason string) error { - return c.doPost(fmt.Sprintf("/v1/agents/%s/revoke", agentID), &RevokeAgentRequest{ + return c.doPost(fmt.Sprintf("/v1/agents/%s/revoke", url.PathEscape(agentID)), &RevokeAgentRequest{ KID: kid, Reason: reason, }, nil) @@ -54,7 +55,7 @@ func (c *Client) ListAgents() (*ListAgentsResponse, error) { // UnfreezeAgent unfreezes a previously frozen agent. func (c *Client) UnfreezeAgent(agentID, reason string) (*UnfreezeAgentResponse, error) { var result UnfreezeAgentResponse - if err := c.doPost(fmt.Sprintf("/v1/agents/%s/unfreeze", agentID), &UnfreezeAgentRequest{ + if err := c.doPost(fmt.Sprintf("/v1/agents/%s/unfreeze", url.PathEscape(agentID)), &UnfreezeAgentRequest{ Reason: reason, }, &result); err != nil { return nil, err @@ -65,7 +66,7 @@ func (c *Client) UnfreezeAgent(agentID, reason string) (*UnfreezeAgentResponse, // UpdateAgent updates an agent's integration type. func (c *Client) UpdateAgent(agentID string, integrationType IntegrationType) (*UpdateAgentResponse, error) { var result UpdateAgentResponse - if err := c.doRequest(http.MethodPatch, fmt.Sprintf("/v1/agents/%s", agentID), &UpdateAgentRequest{ + if err := c.doRequest(http.MethodPatch, fmt.Sprintf("/v1/agents/%s", url.PathEscape(agentID)), &UpdateAgentRequest{ IntegrationType: integrationType, }, &result); err != nil { return nil, err @@ -76,7 +77,7 @@ func (c *Client) UpdateAgent(agentID string, integrationType IntegrationType) (* // DeleteAgent permanently deletes an agent and all associated data. func (c *Client) DeleteAgent(agentID string) (*DeleteAgentResponse, error) { var result DeleteAgentResponse - if err := c.doRequest(http.MethodDelete, fmt.Sprintf("/v1/agents/%s", agentID), nil, &result); err != nil { + if err := c.doRequest(http.MethodDelete, fmt.Sprintf("/v1/agents/%s", url.PathEscape(agentID)), nil, &result); err != nil { return nil, err } return &result, nil diff --git a/client.go b/client.go index e40af34..14d70c7 100644 --- a/client.go +++ b/client.go @@ -151,7 +151,16 @@ func retryable(status int) bool { return status >= 500 || status == http.StatusTooManyRequests } -// doRequest executes an authenticated request, retrying transport and 5xx failures. +// A lost response to a non-idempotent request must not be replayed. +func idempotent(method string) bool { + switch method { + case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodPut, http.MethodDelete: + return true + } + return false +} + +// doRequest executes an authenticated request; only idempotent methods retry. func (c *Client) doRequest(method, path string, body interface{}, result interface{}) error { data, err := marshalBody(body) if err != nil { @@ -169,18 +178,24 @@ func (c *Client) doRequest(method, path string, body interface{}, result interfa resp, err := c.httpClient.Do(req) if err != nil { lastErr = fmt.Errorf("elydora: http request: %w", err) + if !idempotent(method) { + return lastErr + } continue } respBody, err := readBody(resp, maxResponseBytes) if err != nil { lastErr = err + if !idempotent(method) { + return lastErr + } continue } if resp.StatusCode >= 200 && resp.StatusCode < 300 { return decodeSuccess(respBody, result) } lastErr = apiError(resp.StatusCode, respBody) - if !retryable(resp.StatusCode) { + if !retryable(resp.StatusCode) || !idempotent(method) { return lastErr } } diff --git a/types_api.go b/types_api.go index 3a2a807..8098b9b 100644 --- a/types_api.go +++ b/types_api.go @@ -79,9 +79,10 @@ type IssueApiTokenResponse struct { } type RotateApiTokenResponse struct { - Token string `json:"token"` - ExpiresAt *int64 `json:"expires_at"` - TokenID string `json:"token_id"` + Token string `json:"token"` + ExpiresAt *int64 `json:"expires_at"` + TokenID string `json:"token_id"` + PreviousTokenGraceUntil int64 `json:"previous_token_grace_until"` } type HealthResponse struct {