mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 17:11:30 +00:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user