Skip to content

Commit 709698f

Browse files
committed
feat: add automatic 409 FGAM configuration conflict handling
1 parent c3b8ecb commit 709698f

File tree

7 files changed

+514
-96
lines changed

7 files changed

+514
-96
lines changed

internal/client/cloud_client.go

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -110,15 +110,84 @@ func (c *CloudClient) Do(req *http.Request) (*ResponseWithETag, error) {
110110

111111
// UpdateResource updates any resource that implements the Resource interface
112112
func (c *CloudClient) UpdateResource(resource Resource, endpoint string, body any) (Resource, error) {
113-
req, err := c.NewRequest(http.MethodPut, endpoint, body, WithETag(resource.GetETag()))
114-
if err != nil {
115-
return nil, err
113+
// Define a function to get the latest ETag
114+
getLatestETag := func() (string, error) {
115+
getReq, err := c.NewRequest(http.MethodGet, endpoint, nil)
116+
if err != nil {
117+
return "", fmt.Errorf("failed to create GET request: %w", err)
118+
}
119+
120+
getResp, err := c.Do(getReq)
121+
if err != nil {
122+
return "", fmt.Errorf("failed to send GET request: %w", err)
123+
}
124+
defer func() {
125+
if getResp.Response.Body != nil {
126+
_ = getResp.Response.Body.Close()
127+
}
128+
}()
129+
130+
if getResp.Response.StatusCode != http.StatusOK {
131+
return "", fmt.Errorf("failed to get latest ETag, status: %d", getResp.Response.StatusCode)
132+
}
133+
134+
// Extract ETag from response header
135+
return getResp.ETag, nil
116136
}
117137

118-
respWithETag, err := c.Do(req)
138+
// Try update with current ETag
139+
updateWithETag := func(currentETag string) (*ResponseWithETag, error) {
140+
req, err := c.NewRequest(http.MethodPut, endpoint, body, WithETag(currentETag))
141+
if err != nil {
142+
return nil, err
143+
}
144+
145+
return c.Do(req)
146+
}
147+
148+
respWithETag, err := updateWithETag(resource.GetETag())
119149
if err != nil {
120150
return nil, err
121151
}
152+
153+
// Handle the 412 Precondition Failed error by retrying with the latest ETag
154+
if respWithETag.Response.StatusCode == http.StatusPreconditionFailed {
155+
// Close the body of the first response
156+
if respWithETag.Response.Body != nil {
157+
_ = respWithETag.Response.Body.Close()
158+
}
159+
160+
// Get the latest ETag
161+
latestETag, err := getLatestETag()
162+
if err != nil {
163+
return nil, fmt.Errorf("failed to get latest ETag for retry: %w", err)
164+
}
165+
166+
// Retry the update with the latest ETag
167+
respWithETag, err = updateWithETag(latestETag)
168+
if err != nil {
169+
return nil, err
170+
}
171+
}
172+
173+
if respWithETag.Response.StatusCode == http.StatusConflict {
174+
// Close the body of the first response
175+
if respWithETag.Response.Body != nil {
176+
_ = respWithETag.Response.Body.Close()
177+
}
178+
179+
latestETag, err := getLatestETag()
180+
if err != nil {
181+
return nil, fmt.Errorf("failed to get latest ETag after FGAM configuration change: %w", err)
182+
}
183+
184+
// Retry the update with the fresh ETag
185+
respWithETag, err = updateWithETag(latestETag)
186+
if err != nil {
187+
return nil, err
188+
}
189+
}
190+
122191
defer func() {
123192
// ignore the error
124193
_ = respWithETag.Response.Body.Close()

internal/client/errors.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"io"
77
"net/http"
8+
"strings"
89
)
910

1011
// HTTPResponder interface for any type that can provide an HTTP response
@@ -36,11 +37,23 @@ type APIError struct {
3637

3738
func (e *APIError) Error() string {
3839
if e.Message != "" {
40+
// Check if this is a FGAM configuration conflict and provide helpful context
41+
if e.StatusCode == 409 && containsFGAMConfigConflict(e.Message) {
42+
return fmt.Sprintf("API error (status %d): %s\n\nThis error occurs when the Fine-Grained Access Management (FGAM) configuration for the permission system has been modified by another process. The Terraform provider will automatically retry this operation.", e.StatusCode, e.Message)
43+
}
3944
return fmt.Sprintf("API error (status %d): %s", e.StatusCode, e.Message)
4045
}
4146
return fmt.Sprintf("API error (status %d)", e.StatusCode)
4247
}
4348

49+
// containsFGAMConfigConflict checks if the error message indicates a FGAM configuration conflict
50+
func containsFGAMConfigConflict(message string) bool {
51+
lowerMessage := strings.ToLower(message)
52+
53+
return strings.Contains(lowerMessage, "restricted api access configuration") &&
54+
strings.Contains(lowerMessage, "has changed")
55+
}
56+
4457
// NewAPIError creates a new APIError from an HTTPResponder
4558
func NewAPIError(responder HTTPResponder) *APIError {
4659
resp := responder.GetResponse()

internal/client/policy.go

Lines changed: 122 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -97,20 +97,95 @@ func (c *CloudClient) CreatePolicy(policy *models.Policy) (*PolicyWithETag, erro
9797
func (c *CloudClient) UpdatePolicy(policy *models.Policy, etag string) (*PolicyWithETag, error) {
9898
path := fmt.Sprintf("/ps/%s/access/policies/%s", policy.PermissionsSystemID, policy.ID)
9999

100-
// Create a direct PUT request without using UpdateResource
101-
req, err := c.NewRequest(http.MethodPut, path, policy)
102-
if err != nil {
103-
return nil, fmt.Errorf("failed to create request: %w", err)
100+
// Define a function to get the latest ETag
101+
getLatestETag := func() (string, error) {
102+
getReq, err := c.NewRequest(http.MethodGet, path, nil)
103+
if err != nil {
104+
return "", fmt.Errorf("failed to create GET request: %w", err)
105+
}
106+
107+
getResp, err := c.Do(getReq)
108+
if err != nil {
109+
return "", fmt.Errorf("failed to send GET request: %w", err)
110+
}
111+
defer func() {
112+
if getResp.Response.Body != nil {
113+
_ = getResp.Response.Body.Close()
114+
}
115+
}()
116+
117+
if getResp.Response.StatusCode != http.StatusOK {
118+
return "", fmt.Errorf("failed to get latest ETag, status: %d", getResp.Response.StatusCode)
119+
}
120+
121+
// Extract ETag from response header
122+
latestETag := getResp.ETag
123+
return latestETag, nil
104124
}
105125

106-
// Only set If-Match header if we have a non-empty ETag
107-
if etag != "" {
108-
req.Header.Set("If-Match", etag)
126+
// Try update with provided ETag
127+
updateWithETag := func(currentETag string) (*ResponseWithETag, error) {
128+
req, err := c.NewRequest(http.MethodPut, path, policy)
129+
if err != nil {
130+
return nil, fmt.Errorf("failed to create request: %w", err)
131+
}
132+
133+
// Only set If-Match header if we have a non-empty ETag
134+
if currentETag != "" {
135+
req.Header.Set("If-Match", currentETag)
136+
}
137+
138+
respWithETag, err := c.Do(req)
139+
if err != nil {
140+
return nil, fmt.Errorf("failed to send request: %w", err)
141+
}
142+
143+
return respWithETag, nil
109144
}
110145

111-
respWithETag, err := c.Do(req)
146+
// First attempt with the provided ETag
147+
respWithETag, err := updateWithETag(etag)
112148
if err != nil {
113-
return nil, fmt.Errorf("failed to send request: %w", err)
149+
return nil, err
150+
}
151+
152+
// Handle the 412 Precondition Failed error by retrying with the latest ETag
153+
if respWithETag.Response.StatusCode == http.StatusPreconditionFailed {
154+
// Close the body of the first response
155+
if respWithETag.Response.Body != nil {
156+
_ = respWithETag.Response.Body.Close()
157+
}
158+
159+
// Get the latest ETag
160+
latestETag, err := getLatestETag()
161+
if err != nil {
162+
return nil, fmt.Errorf("failed to get latest ETag for retry: %w", err)
163+
}
164+
165+
// Retry the update with the latest ETag
166+
respWithETag, err = updateWithETag(latestETag)
167+
if err != nil {
168+
return nil, err
169+
}
170+
}
171+
172+
// Handle 409 Conflict error, FGAM config changes, by retrying with fresh ETag
173+
if respWithETag.Response.StatusCode == http.StatusConflict {
174+
if respWithETag.Response.Body != nil {
175+
_ = respWithETag.Response.Body.Close()
176+
}
177+
178+
// Get the latest ETag after FGAM configuration change
179+
latestETag, err := getLatestETag()
180+
if err != nil {
181+
return nil, fmt.Errorf("failed to get latest ETag after FGAM configuration change: %w", err)
182+
}
183+
184+
// Retry the update with the fresh ETag
185+
respWithETag, err = updateWithETag(latestETag)
186+
if err != nil {
187+
return nil, err
188+
}
114189
}
115190

116191
// Keep the response body for potential error reporting
@@ -127,53 +202,54 @@ func (c *CloudClient) UpdatePolicy(policy *models.Policy, etag string) (*PolicyW
127202
}
128203
}()
129204

130-
if respWithETag.Response.StatusCode != http.StatusOK {
131-
// If it's a 404 error, attempt to recreate the resource
132-
if respWithETag.Response.StatusCode == http.StatusNotFound {
133-
// Recreate the policy using POST to the base endpoint
134-
createPath := fmt.Sprintf("/ps/%s/access/policies", policy.PermissionsSystemID)
135-
originalID := policy.ID
136-
137-
createReq, err := c.NewRequest(http.MethodPost, createPath, policy)
138-
if err != nil {
139-
return nil, fmt.Errorf("failed to create request for recreation: %w", err)
140-
}
205+
// Handle 404 Not Found
206+
if respWithETag.Response.StatusCode == http.StatusNotFound {
207+
// Recreate the policy using POST to the base endpoint
208+
createPath := fmt.Sprintf("/ps/%s/access/policies", policy.PermissionsSystemID)
209+
originalID := policy.ID
141210

142-
createResp, err := c.Do(createReq)
143-
if err != nil {
144-
return nil, fmt.Errorf("failed to send create request for recreation: %w", err)
145-
}
211+
createReq, err := c.NewRequest(http.MethodPost, createPath, policy)
212+
if err != nil {
213+
return nil, fmt.Errorf("failed to create request for recreation: %w", err)
214+
}
146215

147-
defer func() {
148-
if createResp.Response.Body != nil {
149-
_ = createResp.Response.Body.Close()
150-
}
151-
}()
216+
createResp, err := c.Do(createReq)
217+
if err != nil {
218+
return nil, fmt.Errorf("failed to send create request for recreation: %w", err)
219+
}
152220

153-
if createResp.Response.StatusCode != http.StatusCreated {
154-
return nil, NewAPIError(createResp)
221+
defer func() {
222+
if createResp.Response.Body != nil {
223+
_ = createResp.Response.Body.Close()
155224
}
225+
}()
156226

157-
// Decode the created policy
158-
var createdPolicy models.Policy
159-
if err := json.NewDecoder(createResp.Response.Body).Decode(&createdPolicy); err != nil {
160-
return nil, fmt.Errorf("failed to decode recreated policy: %w", err)
161-
}
227+
if createResp.Response.StatusCode != http.StatusCreated {
228+
return nil, NewAPIError(createResp)
229+
}
162230

163-
// Create the result with the original ID to maintain consistency
164-
result := &PolicyWithETag{
165-
Policy: &createdPolicy,
166-
ETag: createResp.ETag,
167-
}
231+
// Decode the created policy
232+
var createdPolicy models.Policy
233+
if err := json.NewDecoder(createResp.Response.Body).Decode(&createdPolicy); err != nil {
234+
return nil, fmt.Errorf("failed to decode recreated policy: %w", err)
235+
}
168236

169-
// Force the right ID to maintain Terraform state consistency
170-
if result.Policy.ID != originalID {
171-
result.Policy.ID = originalID
172-
}
237+
// Create the result with the original ID to maintain consistency
238+
result := &PolicyWithETag{
239+
Policy: &createdPolicy,
240+
ETag: createResp.ETag,
241+
}
173242

174-
return result, nil
243+
// Force the right ID to maintain Terraform state consistency
244+
if result.Policy.ID != originalID {
245+
result.Policy.ID = originalID
175246
}
176247

248+
return result, nil
249+
}
250+
251+
// Handle other error status codes
252+
if respWithETag.Response.StatusCode != http.StatusOK {
177253
return nil, NewAPIError(respWithETag)
178254
}
179255

0 commit comments

Comments
 (0)