|
| 1 | +// © 2025 Platform Engineering Labs Inc. |
| 2 | +// |
| 3 | +// SPDX-License-Identifier: FSL-1.1-ALv2 |
| 4 | + |
| 5 | +package ecs |
| 6 | + |
| 7 | +import ( |
| 8 | + "errors" |
| 9 | + "net" |
| 10 | + |
| 11 | + "github.com/aws/smithy-go" |
| 12 | + |
| 13 | + "github.com/platform-engineering-labs/formae/pkg/plugin/resource" |
| 14 | +) |
| 15 | + |
| 16 | +// classifyAWSError maps an error from an AWS SDK call (or our own ccx layer) to |
| 17 | +// a (errorCode, retryable) verdict. See design Q5 for the full mapping. |
| 18 | +// |
| 19 | +// Discipline: unknown errors default to TERMINAL (GeneralServiceException) — fail |
| 20 | +// loudly rather than poll forever on something we don't recognize. The 20-minute |
| 21 | +// operation timeout (via inProgressOrTimeout) means even retryable verdicts can |
| 22 | +// escalate to terminal Failure if they persist. |
| 23 | +func classifyAWSError(err error) (resource.OperationErrorCode, bool) { |
| 24 | + if err == nil { |
| 25 | + return "", false |
| 26 | + } |
| 27 | + var ae smithy.APIError |
| 28 | + if errors.As(err, &ae) { |
| 29 | + switch ae.ErrorCode() { |
| 30 | + // Retryable |
| 31 | + case "Throttling", "ThrottlingException", "RequestLimitExceeded", |
| 32 | + "TooManyRequestsException": |
| 33 | + return resource.OperationErrorCodeThrottling, true |
| 34 | + case "ServiceUnavailable", "ServiceUnavailableException": |
| 35 | + return resource.OperationErrorCodeServiceInternalError, true |
| 36 | + case "InternalFailure", "InternalServerError": |
| 37 | + return resource.OperationErrorCodeServiceInternalError, true |
| 38 | + // Terminal — auth/permissions |
| 39 | + case "AccessDenied", "AccessDeniedException", "UnauthorizedOperation": |
| 40 | + return resource.OperationErrorCodeAccessDenied, false |
| 41 | + case "ExpiredToken", "InvalidClientTokenId", |
| 42 | + "InvalidSignatureException", "SignatureDoesNotMatch": |
| 43 | + return resource.OperationErrorCodeInvalidCredentials, false |
| 44 | + // Terminal — validation |
| 45 | + case "ValidationException", "InvalidParameterException", |
| 46 | + "InvalidParameterValueException", "InvalidInputException": |
| 47 | + return resource.OperationErrorCodeInvalidRequest, false |
| 48 | + } |
| 49 | + } |
| 50 | + // Network errors with .Timeout() == true → retryable |
| 51 | + var netErr net.Error |
| 52 | + if errors.As(err, &netErr) && netErr.Timeout() { |
| 53 | + return resource.OperationErrorCodeNetworkFailure, true |
| 54 | + } |
| 55 | + return resource.OperationErrorCodeGeneralServiceException, false |
| 56 | +} |
| 57 | + |
| 58 | +// classifyForEntry is used by Create/Update entry. Retryable AWS errors return |
| 59 | +// a recoverable ErrorCode so the operator's handlePluginResult schedules a CRUD |
| 60 | +// retry of the entire operation. Terminal errors get a non-recoverable code so |
| 61 | +// the operator surfaces the failure immediately. |
| 62 | +func classifyForEntry(err error, op resource.Operation, nativeID, contextMsg string) *resource.ProgressResult { |
| 63 | + code, retryable := classifyAWSError(err) |
| 64 | + if retryable { |
| 65 | + // Map our verdict to a recoverable code the SDK's recoverableErrorCodes |
| 66 | + // table actually recognises. (Throttling, NetworkFailure, ServiceInternalError |
| 67 | + // are all in the table — see pkg/plugin/resource/resource.go:172-181.) |
| 68 | + switch code { |
| 69 | + case resource.OperationErrorCodeThrottling, |
| 70 | + resource.OperationErrorCodeNetworkFailure, |
| 71 | + resource.OperationErrorCodeServiceInternalError: |
| 72 | + // already recoverable |
| 73 | + default: |
| 74 | + code = resource.OperationErrorCodeThrottling |
| 75 | + } |
| 76 | + } |
| 77 | + return terminalFailurePR(op, nativeID, "", code, contextMsg+": "+err.Error()) |
| 78 | +} |
| 79 | + |
| 80 | +// terminalFailurePR builds a populated Failure ProgressResult. |
| 81 | +func terminalFailurePR(op resource.Operation, nativeID, requestID string, |
| 82 | + code resource.OperationErrorCode, msg string) *resource.ProgressResult { |
| 83 | + return &resource.ProgressResult{ |
| 84 | + Operation: op, |
| 85 | + OperationStatus: resource.OperationStatusFailure, |
| 86 | + NativeID: nativeID, |
| 87 | + RequestID: requestID, |
| 88 | + ErrorCode: code, |
| 89 | + StatusMessage: msg, |
| 90 | + } |
| 91 | +} |
| 92 | + |
| 93 | +// classifyReadResultForFinal classifies a post-stability Read outcome. Handles |
| 94 | +// both Go errors and ReadResult.ErrorCode (ccx.ReadResource maps CCAPI errors |
| 95 | +// into ErrorCode without returning a Go error — see pkg/ccx/client.go:294-303). |
| 96 | +// |
| 97 | +// Returns: |
| 98 | +// - ok=true: Read returned non-empty Properties → caller emits Success |
| 99 | +// - ok=false, retryable=true: route through inProgressOrFinalReadTimeout (grace-bounded) |
| 100 | +// - ok=false, retryable=false: terminal Failure with `code` |
| 101 | +func classifyReadResultForFinal(rr *resource.ReadResult, readErr error) (resource.OperationErrorCode, bool, bool) { |
| 102 | + if readErr != nil { |
| 103 | + code, retryable := classifyAWSError(readErr) |
| 104 | + return code, retryable, false |
| 105 | + } |
| 106 | + if rr == nil { |
| 107 | + return resource.OperationErrorCodeGeneralServiceException, false, false |
| 108 | + } |
| 109 | + switch rr.ErrorCode { |
| 110 | + case "": |
| 111 | + if rr.Properties == "" { |
| 112 | + return "", true, false // retryable: empty body without error |
| 113 | + } |
| 114 | + return "", false, true // success |
| 115 | + case resource.OperationErrorCodeNotFound, |
| 116 | + resource.OperationErrorCodeThrottling, |
| 117 | + resource.OperationErrorCodeServiceInternalError, |
| 118 | + resource.OperationErrorCodeServiceTimeout, |
| 119 | + resource.OperationErrorCodeNetworkFailure, |
| 120 | + resource.OperationErrorCodeInternalFailure: |
| 121 | + return rr.ErrorCode, true, false |
| 122 | + case resource.OperationErrorCodeAccessDenied, |
| 123 | + resource.OperationErrorCodeInvalidCredentials, |
| 124 | + resource.OperationErrorCodeInvalidRequest: |
| 125 | + return rr.ErrorCode, false, false |
| 126 | + default: |
| 127 | + return resource.OperationErrorCodeGeneralServiceException, false, false |
| 128 | + } |
| 129 | +} |
0 commit comments