refactor: extract AWS error envelope rendering to ingress

ingress/aws/envelope owns the EC2, IAM-style and S3 XML error envelopes, the
AWS JSON 1.1 error body and X-Amzn-Errortype value, the JSON-protocol header
detector and the two error content types. Each shape keeps its own builder.

The gateway error writers, ErrorHandler and the legacy per-service envelope
selection stay in gateway and call the new package; gateway/eks renders its
JSON errors through it directly. A 140-case status/header/body golden
comparison across services and error writers was identical before and after.
This commit is contained in:
joshsiv-mulga
2026-10-07 23:27:20 +10:00
parent 88c7397f45
commit 5f66bd2a36
12 changed files with 485 additions and 417 deletions
+6 -6
View File
@@ -15,10 +15,10 @@ import (
"github.com/mulgadc/bluebottle/pkg/sigv4"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
telemetry "github.com/mulgadc/spinifex/spinifex/foundation/telemetry"
gateway_eks "github.com/mulgadc/spinifex/spinifex/gateway/eks"
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
handlers_sts "github.com/mulgadc/spinifex/spinifex/handlers/sts"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/envelope"
ingresshttp "github.com/mulgadc/spinifex/spinifex/ingress/http"
)
@@ -526,17 +526,17 @@ func (gw *GatewayConfig) writeSigV4Error(w http.ResponseWriter, r *http.Request,
// the SDK chokes deserializing our XML into its shape. An unserved scope is
// absent from jsonErrorService by definition, so its protocol is read off
// the request itself instead.
if gw.jsonErrorService(svc) || requestSignalsJSONProtocol(r) {
w.Header().Set("Content-Type", eksJSONContentType)
w.Header().Set("X-Amzn-Errortype", jsonErrorType(errorCode))
if gw.jsonErrorService(svc) || envelope.RequestSignalsJSONProtocol(r) {
w.Header().Set("Content-Type", envelope.JSONContentType)
w.Header().Set("X-Amzn-Errortype", envelope.JSONErrorType(errorCode))
w.WriteHeader(errorMsg.HTTPCode)
_, _ = w.Write(gateway_eks.GenerateEKSErrorResponse(errorCode, errorMsg.Message))
_, _ = w.Write(envelope.JSONBody(errorCode, errorMsg.Message))
return
}
xmlError := xmlErrorBody(svc, errorCode, errorMsg.Message, requestID, r.URL.Path)
w.Header().Set("Content-Type", "application/xml")
w.Header().Set("Content-Type", envelope.XMLContentType)
w.WriteHeader(errorMsg.HTTPCode)
_, _ = w.Write(xmlError)
}
+4 -3
View File
@@ -10,6 +10,7 @@ import (
"fmt"
awsapi "github.com/mulgadc/spinifex/spinifex/domains/ecr/awsapi"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/envelope"
authlimit "github.com/mulgadc/spinifex/spinifex/ingress/aws/ratelimit"
"io"
"net/http"
@@ -2621,7 +2622,7 @@ func TestSigV4Auth_UnservedServiceJSONClientGetsJSONEnvelope(t *testing.T) {
resp := doRequest(handler, req)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
assert.Equal(t, eksJSONContentType, resp.Header.Get("Content-Type"))
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
assert.Equal(t, "InvalidActionException", resp.Header.Get("X-Amzn-Errortype"))
body, err := io.ReadAll(resp.Body)
@@ -2642,7 +2643,7 @@ func TestSigV4Auth_UnservedServiceJSONContentTypeGetsJSONEnvelope(t *testing.T)
resp := doRequest(handler, req)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
assert.Equal(t, eksJSONContentType, resp.Header.Get("Content-Type"))
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
assert.Equal(t, "InvalidActionException", resp.Header.Get("X-Amzn-Errortype"))
body, err := io.ReadAll(resp.Body)
@@ -2685,7 +2686,7 @@ func TestWriteSigV4Error_JSONErrorServicesUnaffectedByFallback(t *testing.T) {
gw.writeSigV4Error(w, req, awserrors.ErrorSignatureDoesNotMatch, "")
resp := w.Result()
assert.Equal(t, eksJSONContentType, resp.Header.Get("Content-Type"))
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
assert.Equal(t, "SignatureDoesNotMatchException", resp.Header.Get("X-Amzn-Errortype"))
})
}
+5 -4
View File
@@ -22,6 +22,7 @@ import (
"github.com/mulgadc/spinifex/spinifex/foundation/telemetry"
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/envelope"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel"
@@ -315,10 +316,10 @@ func TestDispatchSeam_DispatcherErrorRendersInDeclaredEnvelope(t *testing.T) {
_, app := dispatchSeamApp(t, allowAllECRDocs(), fakeJSONRegistration(capt))
resp := doRequest(app, signDispatchRequest(t, fakeJSONService, []byte("{}")))
assert.Equal(t, eksJSONContentType, resp.Header.Get("Content-Type"))
assert.Equal(t, jsonErrorType(awserrors.ErrorInternalError), resp.Header.Get("X-Amzn-Errortype"))
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
assert.Equal(t, envelope.JSONErrorType(awserrors.ErrorInternalError), resp.Header.Get("X-Amzn-Errortype"))
body := decodeJSONError(t, resp)
assert.Equal(t, jsonErrorType(awserrors.ErrorInternalError), body.Type)
assert.Equal(t, envelope.JSONErrorType(awserrors.ErrorInternalError), body.Type)
})
t.Run("XML envelope service", func(t *testing.T) {
@@ -350,7 +351,7 @@ func TestDispatchSeam_NATSDownGetsSame503AsLegacy(t *testing.T) {
resp := w.Result()
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
assert.Equal(t, eksJSONContentType, resp.Header.Get("Content-Type"))
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
body := decodeJSONError(t, resp)
assert.Equal(t, "ServiceUnavailableException", body.Type)
}
@@ -24,6 +24,7 @@ import (
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/telemetry"
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/envelope"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel"
@@ -127,7 +128,7 @@ func TestECRPipeline_MissingTarget_400MissingActionException(t *testing.T) {
resp := doRequest(app, signECRRequest(t, "", []byte("{}")))
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
assert.Equal(t, eksJSONContentType, resp.Header.Get("Content-Type"))
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
assert.Equal(t, "MissingActionException", resp.Header.Get("X-Amzn-Errortype"))
body := decodeJSONError(t, resp)
assert.Equal(t, "MissingActionException", body.Type)
@@ -141,7 +142,7 @@ func TestECRPipeline_UnknownAction_400InvalidActionException(t *testing.T) {
resp := doRequest(app, signECRRequest(t, target, []byte("{}")))
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
assert.Equal(t, eksJSONContentType, resp.Header.Get("Content-Type"))
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
assert.Equal(t, "InvalidActionException", resp.Header.Get("X-Amzn-Errortype"))
body := decodeJSONError(t, resp)
assert.Equal(t, "InvalidActionException", body.Type)
@@ -156,7 +157,7 @@ func TestECRPipeline_StubbedAction_501NotImplementedException(t *testing.T) {
resp := doRequest(app, signECRRequest(t, target, []byte("{}")))
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
assert.Equal(t, eksJSONContentType, resp.Header.Get("Content-Type"))
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
assert.Equal(t, "NotImplementedException", resp.Header.Get("X-Amzn-Errortype"))
body := decodeJSONError(t, resp)
assert.Equal(t, "NotImplementedException", body.Type)
@@ -318,7 +319,7 @@ func TestECRPipeline_OversizedBody_413OverHTTP(t *testing.T) {
resp := doRequest(handler, req)
require.Equal(t, http.StatusRequestEntityTooLarge, resp.StatusCode)
assert.Equal(t, eksJSONContentType, resp.Header.Get("Content-Type"))
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
body := decodeJSONError(t, resp)
assert.Equal(t, "RequestEntityTooLargeException", body.Type)
}
@@ -444,7 +445,7 @@ func TestRequest_ClusterUnavailableNilConn_ECR(t *testing.T) {
resp := w.Result()
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
assert.Equal(t, eksJSONContentType, resp.Header.Get("Content-Type"))
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"))
body := decodeJSONError(t, resp)
assert.Equal(t, "ServiceUnavailableException", body.Type)
})
-11
View File
@@ -8,7 +8,6 @@ import (
"log/slog"
"net/http"
"slices"
"strings"
"github.com/mulgadc/bluebottle/pkg/auth"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/arn"
@@ -16,16 +15,6 @@ import (
gateway_eks "github.com/mulgadc/spinifex/spinifex/gateway/eks"
)
// jsonErrorType derives the X-Amzn-Errortype header value for code, mirroring
// gateway_eks.GenerateEKSErrorResponse's own "Exception" suffixing so the header and the
// body's __type always agree.
func jsonErrorType(code string) string {
if strings.HasSuffix(code, "Exception") {
return code
}
return code + "Exception"
}
// eksRoute maps one HTTP method + chi path pattern to an AWS action and handler.
type eksRoute = rest.Route[eksRouteHandler]
+2 -30
View File
@@ -4,45 +4,17 @@
package gateway_eks
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strings"
"github.com/aws/aws-sdk-go/private/protocol/json/jsonutil"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/envelope"
)
// JSONContentType is the AWS REST-JSON 1.1 content type EKS clients expect.
const JSONContentType = "application/x-amz-json-1.1"
// EKSJSONError is the AWS REST-JSON error envelope. SDKs key off __type for
// awserr.Code() and message for awserr.Message().
type EKSJSONError struct {
Type string `json:"__type"`
Message string `json:"message"`
}
// GenerateEKSErrorResponse marshals the AWS REST-JSON error envelope.
// The suffix "Exception" is appended idempotently — codes that already end in
// it (e.g. "ResourceNotFoundException") are left unchanged to avoid the double
// "ExceptionException" the SDK rejects.
func GenerateEKSErrorResponse(code, message string) []byte {
if !strings.HasSuffix(code, "Exception") {
code += "Exception"
}
body, err := json.Marshal(EKSJSONError{
Type: code,
Message: message,
})
if err != nil {
slog.Error("Failed to marshal EKS error JSON", "code", code, "err", err)
return fmt.Appendf(nil, `{"__type":"InternalErrorException","message":%q}`, message)
}
return body
}
// WriteJSONResponse serializes obj as AWS REST-JSON and writes a 200 response.
// Uses jsonutil.BuildJSON (the SDK's own restjson marshaler) to honour
// locationName tags that encoding/json does not understand.
@@ -63,7 +35,7 @@ func WriteJSONResponse(w http.ResponseWriter, obj any) {
// WriteJSONError emits the AWS REST-JSON error envelope with the given code,
// message, and HTTP status.
func WriteJSONError(w http.ResponseWriter, code, message string, httpStatus int) {
body := GenerateEKSErrorResponse(code, message)
body := envelope.JSONBody(code, message)
w.Header().Set("Content-Type", JSONContentType)
if httpStatus == 0 {
httpStatus = http.StatusInternalServerError
+4 -19
View File
@@ -12,31 +12,16 @@ import (
"github.com/stretchr/testify/require"
)
func TestGenerateEKSErrorResponse_ShapesExceptionSuffix(t *testing.T) {
body := GenerateEKSErrorResponse("ResourceNotFound", "Cluster does not exist")
var env EKSJSONError
require.NoError(t, json.Unmarshal(body, &env))
assert.Equal(t, "ResourceNotFoundException", env.Type)
assert.Equal(t, "Cluster does not exist", env.Message)
}
// Codes that already carry the "Exception" suffix (e.g.
// awserrors.ErrorEKSResourceNotFound = "ResourceNotFoundException") must not be
// doubled into ResourceNotFoundExceptionException, which SDK clients reject.
func TestGenerateEKSErrorResponse_DoesNotDoubleExceptionSuffix(t *testing.T) {
body := GenerateEKSErrorResponse("ResourceNotFoundException", "Cluster does not exist")
var env EKSJSONError
require.NoError(t, json.Unmarshal(body, &env))
assert.Equal(t, eks.ErrCodeResourceNotFoundException, env.Type)
}
func TestWriteJSONError_SetsContentTypeAndStatus(t *testing.T) {
w := httptest.NewRecorder()
WriteJSONError(w, "NotImplemented", "Operation not implemented", http.StatusNotImplemented)
assert.Equal(t, http.StatusNotImplemented, w.Code)
assert.Equal(t, JSONContentType, w.Header().Get("Content-Type"))
var env EKSJSONError
var env struct {
Type string `json:"__type"`
Message string `json:"message"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &env))
assert.Equal(t, "NotImplementedException", env.Type)
assert.Equal(t, "Operation not implemented", env.Message)
+1 -3
View File
@@ -2,7 +2,6 @@ package gateway
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
@@ -215,8 +214,7 @@ func TestErrorHandler_EKSEmitsJSONNotXML(t *testing.T) {
assert.Equal(t, gateway_eks.JSONContentType, w.Header().Get("Content-Type"))
assert.True(t, strings.HasPrefix(w.Body.String(), "{"), "expected JSON body, got %q", w.Body.String())
var env gateway_eks.EKSJSONError
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &env))
env := decodeJSONError(t, w.Result())
assert.Equal(t, "NotImplementedException", env.Type)
// SDKs resolve the modelled exception type from this header, not the body.
assert.Equal(t, env.Type, w.Header().Get("X-Amzn-Errortype"))
+16 -140
View File
@@ -4,7 +4,6 @@ import (
"context"
"crypto/x509"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
@@ -38,12 +37,12 @@ import (
"github.com/mulgadc/spinifex/spinifex/foundation/aws/policy"
"github.com/mulgadc/spinifex/spinifex/foundation/telemetry"
gateway_bedrock "github.com/mulgadc/spinifex/spinifex/gateway/bedrock"
gateway_eks "github.com/mulgadc/spinifex/spinifex/gateway/eks"
gateway_sts "github.com/mulgadc/spinifex/spinifex/gateway/sts"
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
handlers_quota "github.com/mulgadc/spinifex/spinifex/handlers/quota"
handlers_sts "github.com/mulgadc/spinifex/spinifex/handlers/sts"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/envelope"
ingresshttp "github.com/mulgadc/spinifex/spinifex/ingress/http"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
@@ -302,35 +301,6 @@ var supportedServices = map[string]bool{
"bedrock-agent-runtime": true,
}
// EC2ErrorResponse is the EC2 query-API error envelope.
// aws-sdk-go v1's ec2query handler rejects the IAM-style <ErrorResponse> envelope
// with SerializationError, so EC2 errors must use <Response><Errors>...</Errors></Response>.
type EC2ErrorResponse struct {
XMLName xml.Name `xml:"Response"`
Errors EC2Errors `xml:"Errors"`
RequestID string `xml:"RequestID"`
}
type EC2Errors struct {
Error ErrorDetail `xml:"Error"`
}
type ErrorDetail struct {
Code string `xml:"Code"`
Message string `xml:"Message"`
}
// S3ErrorResponse is the S3 REST error envelope: a flat <Error> document rather
// than the query-API wrapper. SDKs look for a top-level <Error><Code> on an S3
// response and report an empty code for anything else.
type S3ErrorResponse struct {
XMLName xml.Name `xml:"Error"`
Code string `xml:"Code"`
Message string `xml:"Message"`
Resource string `xml:"Resource,omitempty"`
RequestID string `xml:"RequestId"`
}
func (gw *GatewayConfig) SetupRoutes() http.Handler {
var logLevel slog.Level
@@ -418,9 +388,6 @@ func (gw *GatewayConfig) throttleKeyFuncs() []ratelimit.KeyFunc {
}
}
// eksJSONContentType is the AWS REST-JSON 1.1 content type EKS clients expect.
const eksJSONContentType = "application/x-amz-json-1.1"
// jsonErrorService reports whether svc returns AWS JSON 1.1 errors rather than
// XML. One source of truth so every error emitter agrees with ErrorHandler; an
// XML body to these clients is an unparseable "<?xml…" deserialization error.
@@ -445,17 +412,6 @@ func (gw *GatewayConfig) jsonErrorService(svc string) bool {
return jsonErrorService(svc)
}
// requestSignalsJSONProtocol reads r's own headers for the AWS JSON-1.x
// tells, for a scope jsonErrorService has no entry for because the gateway
// does not serve it. A JSON-1.1 action always carries X-Amz-Target, and a
// JSON-protocol client always sends an application/x-amz-json-* content type.
func requestSignalsJSONProtocol(r *http.Request) bool {
if r.Header.Get("X-Amz-Target") != "" {
return true
}
return strings.HasPrefix(r.Header.Get("Content-Type"), "application/x-amz-json")
}
// clusterUnavailableMsg is the 503 body when NATS is disconnected. Points
// operators at /local/status rather than leaving the AWS CLI hanging on timeouts.
const clusterUnavailableMsg = "cluster unavailable: NATS disconnected — check daemon /local/status"
@@ -467,9 +423,9 @@ func (gw *GatewayConfig) writeClusterUnavailable(w http.ResponseWriter, r *http.
// AWS JSON 1.1 services (EKS/ECS/bedrock family, …) get a JSON body.
if gw.jsonErrorService(svc) {
body := gateway_eks.GenerateEKSErrorResponse(awserrors.ErrorServiceUnavailable, clusterUnavailableMsg)
w.Header().Set("Content-Type", eksJSONContentType)
w.Header().Set("X-Amzn-Errortype", jsonErrorType(awserrors.ErrorServiceUnavailable))
body := envelope.JSONBody(awserrors.ErrorServiceUnavailable, clusterUnavailableMsg)
w.Header().Set("Content-Type", envelope.JSONContentType)
w.Header().Set("X-Amzn-Errortype", envelope.JSONErrorType(awserrors.ErrorServiceUnavailable))
w.WriteHeader(http.StatusServiceUnavailable)
if _, err := w.Write(body); err != nil {
slog.Error("Failed to write JSON cluster-unavailable response", "err", err)
@@ -479,7 +435,7 @@ func (gw *GatewayConfig) writeClusterUnavailable(w http.ResponseWriter, r *http.
xmlBody := xmlErrorBody(svc, awserrors.ErrorServiceUnavailable, clusterUnavailableMsg, requestID, r.URL.Path)
w.Header().Set("Content-Type", "application/xml")
w.Header().Set("Content-Type", envelope.XMLContentType)
w.WriteHeader(http.StatusServiceUnavailable)
if _, err := w.Write(xmlBody); err != nil {
slog.Error("Failed to write cluster-unavailable response", "err", err)
@@ -499,9 +455,9 @@ func (gw *GatewayConfig) writeThrottleError(w http.ResponseWriter, r *http.Reque
// AWS JSON 1.1 services (EKS/ECS/bedrock family, …) get a JSON body.
if gw.jsonErrorService(svc) {
body := gateway_eks.GenerateEKSErrorResponse(errorCode, errorMsg.Message)
w.Header().Set("Content-Type", eksJSONContentType)
w.Header().Set("X-Amzn-Errortype", jsonErrorType(errorCode))
body := envelope.JSONBody(errorCode, errorMsg.Message)
w.Header().Set("Content-Type", envelope.JSONContentType)
w.Header().Set("X-Amzn-Errortype", envelope.JSONErrorType(errorCode))
w.WriteHeader(errorMsg.HTTPCode)
if _, err := w.Write(body); err != nil {
slog.Error("Failed to write JSON throttle error response", "err", err)
@@ -511,7 +467,7 @@ func (gw *GatewayConfig) writeThrottleError(w http.ResponseWriter, r *http.Reque
xmlErr := xmlErrorBody(svc, errorCode, errorMsg.Message, requestID, r.URL.Path)
w.Header().Set("Content-Type", "application/xml")
w.Header().Set("Content-Type", envelope.XMLContentType)
w.WriteHeader(errorMsg.HTTPCode)
if _, err := w.Write(xmlErr); err != nil {
slog.Error("Failed to write throttle error response", "err", err)
@@ -973,10 +929,10 @@ func (gw *GatewayConfig) ErrorHandler(w http.ResponseWriter, r *http.Request, er
// EKS, ECR, ACM, ECS, tagging, and the bedrock family use AWS JSON 1.1;
// query/XML services fall through.
if gw.jsonErrorService(svc) {
body := gateway_eks.GenerateEKSErrorResponse(code, errorMsg.Message)
body := envelope.JSONBody(code, errorMsg.Message)
slog.Debug("Generated JSON error response", "service", svc, "error", err, "code", code, "json", string(body), "requestId", requestId)
w.Header().Set("Content-Type", eksJSONContentType)
w.Header().Set("X-Amzn-Errortype", jsonErrorType(code))
w.Header().Set("Content-Type", envelope.JSONContentType)
w.Header().Set("X-Amzn-Errortype", envelope.JSONErrorType(code))
w.WriteHeader(errorMsg.HTTPCode)
if _, err := w.Write(body); err != nil {
slog.Error("Failed to write EKS error response", "err", err)
@@ -988,7 +944,7 @@ func (gw *GatewayConfig) ErrorHandler(w http.ResponseWriter, r *http.Request, er
slog.Debug("Generated error response", "error", err, "code", code, "xml", string(xmlError), "requestId", requestId)
w.Header().Set("Content-Type", "application/xml")
w.Header().Set("Content-Type", envelope.XMLContentType)
w.WriteHeader(errorMsg.HTTPCode)
if _, err := w.Write(xmlError); err != nil {
slog.Error("Failed to write error response", "err", err)
@@ -1031,86 +987,6 @@ func ParseAWSQueryArgs(query string) (map[string]string, error) {
return params, nil
}
func GenerateEC2ErrorResponse(code, message, requestID string) (output []byte) {
errorXml := EC2ErrorResponse{
Errors: EC2Errors{
Error: ErrorDetail{
Code: code,
Message: message,
},
},
RequestID: requestID,
}
output, err := xml.MarshalIndent(errorXml, "", " ")
if err != nil {
slog.Error("Failed to build XML", "error", err)
return []byte(xml.Header + `<Response><Errors><Error><Code>InternalError</Code><Message>Internal error</Message></Error></Errors><RequestID>` + requestID + `</RequestID></Response>`)
}
// Add XML header
output = append([]byte(xml.Header), output...)
return output
}
// IAMErrorResponse is the IAM/STS error XML envelope.
type IAMErrorResponse struct {
XMLName xml.Name `xml:"ErrorResponse"`
Error IAMErrorDetail `xml:"Error"`
RequestID string `xml:"RequestId"`
}
type IAMErrorDetail struct {
Type string `xml:"Type"`
Code string `xml:"Code"`
Message string `xml:"Message"`
}
// GenerateIAMErrorResponse builds the generic REST-XML/AWS-query ErrorResponse
// envelope. Originally IAM/STS-specific, it is also xmlErrorBody's default for
// every scope with no dedicated shape, including ones the gateway does not serve.
func GenerateIAMErrorResponse(code, message, requestID string) (output []byte) {
errorXml := IAMErrorResponse{
Error: IAMErrorDetail{
Type: "Sender",
Code: code,
Message: message,
},
RequestID: requestID,
}
output, err := xml.MarshalIndent(errorXml, "", " ")
if err != nil {
slog.Error("Failed to build IAM error XML", "error", err)
return []byte(xml.Header + "<ErrorResponse><Error><Type>Sender</Type><Code>InternalError</Code><Message>Internal error</Message></Error><RequestId>" + requestID + "</RequestId></ErrorResponse>")
}
output = append([]byte(xml.Header), output...)
return output
}
// GenerateS3ErrorResponse builds the flat S3 REST error document. resource is
// the request path and is omitted when empty.
func GenerateS3ErrorResponse(code, message, requestID, resource string) (output []byte) {
errorXml := S3ErrorResponse{
Code: code,
Message: message,
Resource: resource,
RequestID: requestID,
}
output, err := xml.MarshalIndent(errorXml, "", " ")
if err != nil {
slog.Error("Failed to build S3 error XML", "error", err)
return []byte(xml.Header + "<Error><Code>InternalError</Code><Message>Internal error</Message><RequestId>" + requestID + "</RequestId></Error>")
}
output = append([]byte(xml.Header), output...)
return output
}
// xmlErrorBody renders an error in the XML envelope svc's clients expect. The
// one place the service-to-envelope mapping lives, so a service cannot be
// added to some emitters and missed by others. JSON services never reach
@@ -1118,18 +994,18 @@ func GenerateS3ErrorResponse(code, message, requestID, resource string) (output
func xmlErrorBody(svc, code, message, requestID, resource string) []byte {
switch svc {
case "s3":
return GenerateS3ErrorResponse(code, message, requestID, resource)
return envelope.S3Body(code, message, requestID, resource)
case "ec2", "spinifex", "":
// Both speak the Action-parameter query protocol, and their clients
// parse the EC2 <Response><Errors> shape, not the generic ErrorResponse.
// Empty means the signature never parsed, so no service is known yet;
// keep EC2's shape there rather than changing a path this is not about.
return GenerateEC2ErrorResponse(code, message, requestID)
return envelope.EC2Body(code, message, requestID)
default:
// iam, sts, elasticloadbalancing, rds, and any unenumerated or
// unserved scope: the generic REST-XML ErrorResponse envelope, never
// EC2's shape, which a REST-XML client cannot deserialize.
return GenerateIAMErrorResponse(code, message, requestID)
return envelope.IAMBody(code, message, requestID)
}
}
+3 -196
View File
@@ -17,15 +17,13 @@ import (
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/awserr"
awscreds "github.com/aws/aws-sdk-go/aws/credentials"
awssession "github.com/aws/aws-sdk-go/aws/session"
awsec2 "github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/bluebottle/pkg/ratelimit"
clusterv1 "github.com/mulgadc/spinifex/contracts/cluster/v1"
"github.com/mulgadc/spinifex/internal/testkit"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/envelope"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -38,54 +36,6 @@ func doRequest(handler http.Handler, req *http.Request) *http.Response {
return w.Result()
}
func TestGenerateEC2ErrorResponse_Structure(t *testing.T) {
tests := []struct {
name string
code string
message string
requestID string
}{
{
name: "standard error",
code: "InvalidParameterValue",
message: "The value supplied is not valid.",
requestID: "req-12345",
},
{
name: "auth failure",
code: "AuthFailure",
message: "Credentials could not be validated.",
requestID: "req-auth-001",
},
{
name: "empty fields",
code: "",
message: "",
requestID: "",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
output := GenerateEC2ErrorResponse(tc.code, tc.message, tc.requestID)
require.NotNil(t, output)
xmlStr := string(output)
assert.True(t, strings.HasPrefix(xmlStr, xml.Header))
assert.Contains(t, xmlStr, "<Code>"+tc.code+"</Code>")
assert.Contains(t, xmlStr, "<RequestID>"+tc.requestID+"</RequestID>")
// EC2 query API uses <Response>/<Errors>, not <ErrorResponse>; aws-sdk-go v1
// rejects the latter with SerializationError.
assert.Contains(t, xmlStr, "<Response>")
assert.Contains(t, xmlStr, "</Response>")
assert.Contains(t, xmlStr, "<Errors>")
assert.Contains(t, xmlStr, "<Error>")
})
}
}
// xmlRootName returns the local name of body's root element, which is what
// distinguishes the S3, IAM and EC2 error envelopes from each other.
func xmlRootName(t *testing.T, body []byte) string {
@@ -100,32 +50,6 @@ func xmlRootName(t *testing.T, body []byte) string {
}
}
func TestGenerateS3ErrorResponse_FlatEnvelope(t *testing.T) {
output := GenerateS3ErrorResponse("SignatureDoesNotMatch", "bad signature", "req-s3-1", "/bucket/key.txt")
require.NotNil(t, output)
assert.True(t, strings.HasPrefix(string(output), xml.Header))
assert.Equal(t, "Error", xmlRootName(t, output))
var parsed struct {
Code string `xml:"Code"`
Message string `xml:"Message"`
Resource string `xml:"Resource"`
RequestID string `xml:"RequestId"`
}
require.NoError(t, xml.Unmarshal(output, &parsed))
assert.Equal(t, "SignatureDoesNotMatch", parsed.Code)
assert.Equal(t, "bad signature", parsed.Message)
assert.Equal(t, "/bucket/key.txt", parsed.Resource)
assert.Equal(t, "req-s3-1", parsed.RequestID)
}
func TestGenerateS3ErrorResponse_OmitsEmptyResource(t *testing.T) {
output := GenerateS3ErrorResponse("AccessDenied", "denied", "req-s3-2", "")
assert.NotContains(t, string(output), "<Resource>")
}
func TestXMLErrorBody_EnvelopePerService(t *testing.T) {
tests := []struct {
svc string
@@ -154,80 +78,6 @@ func TestXMLErrorBody_EnvelopePerService(t *testing.T) {
}
}
func TestGenerateEC2ErrorResponse_ValidXML(t *testing.T) {
output := GenerateEC2ErrorResponse("TestCode", "Test message", "req-999")
require.NotNil(t, output)
xmlBody := strings.TrimPrefix(string(output), xml.Header)
decoder := xml.NewDecoder(strings.NewReader(xmlBody))
for {
_, err := decoder.Token()
if err != nil {
assert.ErrorIs(t, err, io.EOF)
break
}
}
}
func TestGenerateIAMErrorResponse_Structure(t *testing.T) {
tests := []struct {
name string
code string
message string
requestID string
}{
{
name: "entity not found",
code: "NoSuchEntity",
message: "The request was rejected because it referenced a resource entity that does not exist.",
requestID: "req-iam-001",
},
{
name: "entity already exists",
code: "EntityAlreadyExists",
message: "The request was rejected because it attempted to create a resource that already exists.",
requestID: "req-iam-002",
},
{
name: "empty fields",
code: "",
message: "",
requestID: "",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
output := GenerateIAMErrorResponse(tc.code, tc.message, tc.requestID)
require.NotNil(t, output)
xmlStr := string(output)
assert.True(t, strings.HasPrefix(xmlStr, xml.Header))
assert.Contains(t, xmlStr, "<ErrorResponse>")
assert.Contains(t, xmlStr, "</ErrorResponse>")
assert.Contains(t, xmlStr, "<Type>Sender</Type>")
assert.Contains(t, xmlStr, "<Code>"+tc.code+"</Code>")
assert.Contains(t, xmlStr, "<RequestId>"+tc.requestID+"</RequestId>")
})
}
}
func TestGenerateIAMErrorResponse_ValidXML(t *testing.T) {
output := GenerateIAMErrorResponse("NoSuchEntity", "Entity not found", "req-iam-999")
require.NotNil(t, output)
xmlBody := strings.TrimPrefix(string(output), xml.Header)
decoder := xml.NewDecoder(strings.NewReader(xmlBody))
for {
_, err := decoder.Token()
if err != nil {
assert.ErrorIs(t, err, io.EOF)
break
}
}
}
func TestErrorHandler_IAMService(t *testing.T) {
gw := &GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}
@@ -1599,7 +1449,7 @@ func TestWriteThrottleError_Bedrock(t *testing.T) {
resp := w.Result()
body, _ := io.ReadAll(resp.Body)
assert.Equal(t, eksJSONContentType, resp.Header.Get("Content-Type"), "bedrock must get JSON, not XML")
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"), "bedrock must get JSON, not XML")
assert.Contains(t, string(body), `"__type"`)
assert.NotContains(t, string(body), "<?xml")
// SDKs resolve the modelled exception type from this header, not the body.
@@ -1618,7 +1468,7 @@ func TestWriteSigV4Error_BedrockEmitsJSON(t *testing.T) {
resp := w.Result()
body, _ := io.ReadAll(resp.Body)
assert.Equal(t, 403, resp.StatusCode)
assert.Equal(t, eksJSONContentType, resp.Header.Get("Content-Type"), "a bedrock auth failure must be SDK-parseable JSON")
assert.Equal(t, envelope.JSONContentType, resp.Header.Get("Content-Type"), "a bedrock auth failure must be SDK-parseable JSON")
assert.Contains(t, string(body), "SignatureDoesNotMatch")
assert.NotContains(t, string(body), "<?xml")
// SDKs resolve the modelled exception type from this header, not the body.
@@ -1785,46 +1635,3 @@ func TestRequest_ClusterUnavailableClosedConn(t *testing.T) {
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
}
// TestGenerateEC2ErrorResponse_SDKRoundTrip serves the EC2 error envelope from
// an httptest server, points an aws-sdk-go v1 EC2 client at it, and asserts
// the SDK surfaces the code via awserr.Error.Code() — not SerializationError.
// aws-sdk-go v1's ec2query handler rejects the IAM <ErrorResponse> envelope and
// discards the embedded code, so the EC2 <Response>/<Errors> shape is required.
func TestGenerateEC2ErrorResponse_SDKRoundTrip(t *testing.T) {
const wantCode = "InvalidInstanceType"
const wantMessage = "The instance type 't2.micro' is not supported in this region."
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
body := GenerateEC2ErrorResponse(wantCode, wantMessage, "req-sdk-roundtrip")
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write(body)
}))
defer srv.Close()
sess, err := awssession.NewSession(&aws.Config{
Region: aws.String("us-east-1"),
Endpoint: aws.String(srv.URL),
Credentials: awscreds.NewStaticCredentials("AKIA-TEST", "secret", ""),
DisableSSL: aws.Bool(true),
// Suppress the default retry loop — error responses are not retryable
// here and waiting them out wastes test time.
MaxRetries: aws.Int(0),
})
require.NoError(t, err)
client := awsec2.New(sess)
_, err = client.RunInstances(&awsec2.RunInstancesInput{
ImageId: aws.String("ami-test"),
InstanceType: aws.String("t2.micro"),
MinCount: aws.Int64(1),
MaxCount: aws.Int64(1),
})
require.Error(t, err)
var awsErr awserr.Error
require.ErrorAs(t, err, &awsErr, "expected awserr.Error, got %T: %v", err, err)
assert.Equal(t, wantCode, awsErr.Code())
assert.NotEqual(t, "SerializationError", awsErr.Code(), "SDK could not parse the envelope")
}
+166
View File
@@ -0,0 +1,166 @@
// Package envelope renders the EC2, IAM-style and S3 XML error envelopes and
// the AWS JSON 1.1 error body. It reads no request context and holds no
// per-service table: callers choose the shape.
package envelope
import (
"encoding/json"
"encoding/xml"
"fmt"
"log/slog"
"net/http"
"strings"
)
// JSONContentType is the AWS JSON 1.1 content type JSON-protocol error bodies
// use (EKS, ECS, ACM, tagging, the bedrock family, and any unserved scope a
// client signals JSON for).
const JSONContentType = "application/x-amz-json-1.1"
// XMLContentType is the content type every XML error envelope uses.
const XMLContentType = "application/xml"
// ec2ErrorResponse is the EC2 query-API error envelope.
// aws-sdk-go v1's ec2query handler rejects the IAM-style <ErrorResponse> envelope
// with SerializationError, so EC2 errors must use <Response><Errors>...</Errors></Response>.
type ec2ErrorResponse struct {
XMLName xml.Name `xml:"Response"`
Errors ec2Errors `xml:"Errors"`
RequestID string `xml:"RequestID"`
}
type ec2Errors struct {
Error errorDetail `xml:"Error"`
}
type errorDetail struct {
Code string `xml:"Code"`
Message string `xml:"Message"`
}
// EC2Body builds the EC2 query-API error envelope.
func EC2Body(code, message, requestID string) (output []byte) {
errorXml := ec2ErrorResponse{
Errors: ec2Errors{
Error: errorDetail{
Code: code,
Message: message,
},
},
RequestID: requestID,
}
output, err := xml.MarshalIndent(errorXml, "", " ")
if err != nil {
slog.Error("envelope: failed to build EC2 error XML", "error", err)
return []byte(xml.Header + `<Response><Errors><Error><Code>InternalError</Code><Message>Internal error</Message></Error></Errors><RequestID>` + requestID + `</RequestID></Response>`)
}
return append([]byte(xml.Header), output...)
}
// iamErrorResponse is the IAM/STS error XML envelope.
type iamErrorResponse struct {
XMLName xml.Name `xml:"ErrorResponse"`
Error iamErrorDetail `xml:"Error"`
RequestID string `xml:"RequestId"`
}
type iamErrorDetail struct {
Type string `xml:"Type"`
Code string `xml:"Code"`
Message string `xml:"Message"`
}
// IAMBody builds the generic REST-XML/AWS-query ErrorResponse envelope. This
// is the default shape for every XML scope with no dedicated envelope,
// including scopes the gateway does not serve.
func IAMBody(code, message, requestID string) (output []byte) {
errorXml := iamErrorResponse{
Error: iamErrorDetail{
Type: "Sender",
Code: code,
Message: message,
},
RequestID: requestID,
}
output, err := xml.MarshalIndent(errorXml, "", " ")
if err != nil {
slog.Error("envelope: failed to build IAM error XML", "error", err)
return []byte(xml.Header + "<ErrorResponse><Error><Type>Sender</Type><Code>InternalError</Code><Message>Internal error</Message></Error><RequestId>" + requestID + "</RequestId></ErrorResponse>")
}
return append([]byte(xml.Header), output...)
}
// s3ErrorResponse is the S3 REST error envelope: a flat <Error> document
// rather than the query-API wrapper. SDKs look for a top-level <Error><Code>
// on an S3 response and report an empty code for anything else.
type s3ErrorResponse struct {
XMLName xml.Name `xml:"Error"`
Code string `xml:"Code"`
Message string `xml:"Message"`
Resource string `xml:"Resource,omitempty"`
RequestID string `xml:"RequestId"`
}
// S3Body builds the flat S3 REST error document. resource is the request path
// and is omitted when empty.
func S3Body(code, message, requestID, resource string) (output []byte) {
errorXml := s3ErrorResponse{
Code: code,
Message: message,
Resource: resource,
RequestID: requestID,
}
output, err := xml.MarshalIndent(errorXml, "", " ")
if err != nil {
slog.Error("envelope: failed to build S3 error XML", "error", err)
return []byte(xml.Header + "<Error><Code>InternalError</Code><Message>Internal error</Message><RequestId>" + requestID + "</RequestId></Error>")
}
return append([]byte(xml.Header), output...)
}
// jsonError is the AWS JSON 1.1 error envelope. SDKs key off __type for
// awserr.Code() and message for awserr.Message().
type jsonError struct {
Type string `json:"__type"`
Message string `json:"message"`
}
// JSONBody marshals the AWS JSON 1.1 error body. "Exception" is appended only
// when the code lacks it, avoiding the "ExceptionException" the SDK rejects.
func JSONBody(code, message string) []byte {
body, err := json.Marshal(jsonError{
Type: JSONErrorType(code),
Message: message,
})
if err != nil {
slog.Error("envelope: failed to marshal JSON error", "code", code, "err", err)
return fmt.Appendf(nil, `{"__type":"InternalErrorException","message":%q}`, message)
}
return body
}
// JSONErrorType derives the X-Amzn-Errortype header value for code, mirroring
// JSONBody's own "Exception" suffixing so the header and the body's __type
// always agree.
func JSONErrorType(code string) string {
if strings.HasSuffix(code, "Exception") {
return code
}
return code + "Exception"
}
// RequestSignalsJSONProtocol reports whether r's headers mark it as AWS
// JSON-1.x, for a scope no service table knows: JSON-1.1 actions carry
// X-Amz-Target and JSON clients send an application/x-amz-json-* type.
func RequestSignalsJSONProtocol(r *http.Request) bool {
if r.Header.Get("X-Amz-Target") != "" {
return true
}
return strings.HasPrefix(r.Header.Get("Content-Type"), "application/x-amz-json")
}
@@ -0,0 +1,272 @@
package envelope
import (
"encoding/json"
"encoding/xml"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/awserr"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/aws/aws-sdk-go/service/eks"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestEC2Body_Structure(t *testing.T) {
tests := []struct {
name string
code string
message string
requestID string
}{
{
name: "standard error",
code: "InvalidParameterValue",
message: "The value supplied is not valid.",
requestID: "req-12345",
},
{
name: "auth failure",
code: "AuthFailure",
message: "Credentials could not be validated.",
requestID: "req-auth-001",
},
{
name: "empty fields",
code: "",
message: "",
requestID: "",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
output := EC2Body(tc.code, tc.message, tc.requestID)
require.NotNil(t, output)
xmlStr := string(output)
assert.True(t, strings.HasPrefix(xmlStr, xml.Header))
assert.Contains(t, xmlStr, "<Code>"+tc.code+"</Code>")
assert.Contains(t, xmlStr, "<RequestID>"+tc.requestID+"</RequestID>")
// EC2 query API uses <Response>/<Errors>, not <ErrorResponse>; aws-sdk-go v1
// rejects the latter with SerializationError.
assert.Contains(t, xmlStr, "<Response>")
assert.Contains(t, xmlStr, "</Response>")
assert.Contains(t, xmlStr, "<Errors>")
assert.Contains(t, xmlStr, "<Error>")
})
}
}
// xmlRootName returns the local name of body's root element, which is what
// distinguishes the S3, IAM and EC2 error envelopes from each other.
func xmlRootName(t *testing.T, body []byte) string {
t.Helper()
dec := xml.NewDecoder(strings.NewReader(string(body)))
for {
tok, err := dec.Token()
require.NoError(t, err)
if se, ok := tok.(xml.StartElement); ok {
return se.Name.Local
}
}
}
func TestS3Body_FlatEnvelope(t *testing.T) {
output := S3Body("SignatureDoesNotMatch", "bad signature", "req-s3-1", "/bucket/key.txt")
require.NotNil(t, output)
assert.True(t, strings.HasPrefix(string(output), xml.Header))
assert.Equal(t, "Error", xmlRootName(t, output))
var parsed struct {
Code string `xml:"Code"`
Message string `xml:"Message"`
Resource string `xml:"Resource"`
RequestID string `xml:"RequestId"`
}
require.NoError(t, xml.Unmarshal(output, &parsed))
assert.Equal(t, "SignatureDoesNotMatch", parsed.Code)
assert.Equal(t, "bad signature", parsed.Message)
assert.Equal(t, "/bucket/key.txt", parsed.Resource)
assert.Equal(t, "req-s3-1", parsed.RequestID)
}
func TestS3Body_OmitsEmptyResource(t *testing.T) {
output := S3Body("AccessDenied", "denied", "req-s3-2", "")
assert.NotContains(t, string(output), "<Resource>")
}
func TestEC2Body_ValidXML(t *testing.T) {
output := EC2Body("TestCode", "Test message", "req-999")
require.NotNil(t, output)
xmlBody := strings.TrimPrefix(string(output), xml.Header)
decoder := xml.NewDecoder(strings.NewReader(xmlBody))
for {
_, err := decoder.Token()
if err != nil {
assert.ErrorIs(t, err, io.EOF)
break
}
}
}
func TestIAMBody_Structure(t *testing.T) {
tests := []struct {
name string
code string
message string
requestID string
}{
{
name: "entity not found",
code: "NoSuchEntity",
message: "The request was rejected because it referenced a resource entity that does not exist.",
requestID: "req-iam-001",
},
{
name: "entity already exists",
code: "EntityAlreadyExists",
message: "The request was rejected because it attempted to create a resource that already exists.",
requestID: "req-iam-002",
},
{
name: "empty fields",
code: "",
message: "",
requestID: "",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
output := IAMBody(tc.code, tc.message, tc.requestID)
require.NotNil(t, output)
xmlStr := string(output)
assert.True(t, strings.HasPrefix(xmlStr, xml.Header))
assert.Contains(t, xmlStr, "<ErrorResponse>")
assert.Contains(t, xmlStr, "</ErrorResponse>")
assert.Contains(t, xmlStr, "<Type>Sender</Type>")
assert.Contains(t, xmlStr, "<Code>"+tc.code+"</Code>")
assert.Contains(t, xmlStr, "<RequestId>"+tc.requestID+"</RequestId>")
})
}
}
func TestIAMBody_ValidXML(t *testing.T) {
output := IAMBody("NoSuchEntity", "Entity not found", "req-iam-999")
require.NotNil(t, output)
xmlBody := strings.TrimPrefix(string(output), xml.Header)
decoder := xml.NewDecoder(strings.NewReader(xmlBody))
for {
_, err := decoder.Token()
if err != nil {
assert.ErrorIs(t, err, io.EOF)
break
}
}
}
// TestEC2Body_SDKRoundTrip serves the EC2 error envelope from an httptest
// server, points an aws-sdk-go v1 EC2 client at it, and asserts the SDK
// surfaces the code via awserr.Error.Code() — not SerializationError.
// aws-sdk-go v1's ec2query handler rejects the IAM <ErrorResponse> envelope
// and discards the embedded code, so the EC2 <Response>/<Errors> shape is
// required.
func TestEC2Body_SDKRoundTrip(t *testing.T) {
const wantCode = "InvalidInstanceType"
const wantMessage = "The instance type 't2.micro' is not supported in this region."
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
body := EC2Body(wantCode, wantMessage, "req-sdk-roundtrip")
w.Header().Set("Content-Type", XMLContentType)
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write(body)
}))
defer srv.Close()
sess, err := session.NewSession(&aws.Config{
Region: aws.String("us-east-1"),
Endpoint: aws.String(srv.URL),
Credentials: credentials.NewStaticCredentials("AKIA-TEST", "secret", ""),
DisableSSL: aws.Bool(true),
// Suppress the default retry loop — error responses are not retryable
// here and waiting them out wastes test time.
MaxRetries: aws.Int(0),
})
require.NoError(t, err)
client := ec2.New(sess)
_, err = client.RunInstances(&ec2.RunInstancesInput{
ImageId: aws.String("ami-test"),
InstanceType: aws.String("t2.micro"),
MinCount: aws.Int64(1),
MaxCount: aws.Int64(1),
})
require.Error(t, err)
var reqErr awserr.Error
ok := errors.As(err, &reqErr)
require.True(t, ok, "expected an awserr.Error, got %T: %v", err, err)
assert.Equal(t, wantCode, reqErr.Code())
assert.Equal(t, wantMessage, reqErr.Message())
}
func TestJSONBody_ShapesExceptionSuffix(t *testing.T) {
body := JSONBody("ResourceNotFound", "Cluster does not exist")
var env jsonError
require.NoError(t, json.Unmarshal(body, &env))
assert.Equal(t, "ResourceNotFoundException", env.Type)
assert.Equal(t, "Cluster does not exist", env.Message)
}
// Codes that already carry the "Exception" suffix (e.g.
// awserrors.ErrorEKSResourceNotFound = "ResourceNotFoundException") must not
// be doubled into ResourceNotFoundExceptionException, which SDK clients reject.
func TestJSONBody_DoesNotDoubleExceptionSuffix(t *testing.T) {
body := JSONBody("ResourceNotFoundException", "Cluster does not exist")
var env jsonError
require.NoError(t, json.Unmarshal(body, &env))
assert.Equal(t, eks.ErrCodeResourceNotFoundException, env.Type)
}
func TestJSONErrorType(t *testing.T) {
assert.Equal(t, "ThrottlingException", JSONErrorType("Throttling"))
assert.Equal(t, "ResourceNotFoundException", JSONErrorType("ResourceNotFoundException"))
}
func TestRequestSignalsJSONProtocol(t *testing.T) {
tests := []struct {
name string
setup func(r *http.Request)
signal bool
}{
{"X-Amz-Target present", func(r *http.Request) { r.Header.Set("X-Amz-Target", "Whatever.Action") }, true},
{"json content type", func(r *http.Request) { r.Header.Set("Content-Type", "application/x-amz-json-1.1") }, true},
{"neither header set", func(r *http.Request) {}, false},
{"unrelated content type", func(r *http.Request) { r.Header.Set("Content-Type", "application/xml") }, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/", nil)
tc.setup(r)
assert.Equal(t, tc.signal, RequestSignalsJSONProtocol(r))
})
}
}