Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions adapter_invoke.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ func (a *Adapter) HandleV2(ctx context.Context, event events.APIGatewayV2HTTPReq

func (a *Adapter) valid() error {
if a == nil || a.handler == nil {
return invocationError("validate", ErrInvalidInvocation, "uninitialized adapter")
return invocationError(OperationValidate, ErrInvalidInvocation, "uninitialized adapter")
}
return nil
}
Expand Down Expand Up @@ -135,7 +135,7 @@ func prepareRequest(ctx context.Context, inv *invocation, event decodedEvent, ra
}
ctx, err = identity.NewContext(ctx, caller)
if err != nil {
return nil, invocationError("identity", ErrIdentity, "caller installation failed", identity.ErrConflict)
return nil, invocationError(OperationIdentity, ErrIdentity, "caller installation failed", identity.ErrConflict)
}
request = request.WithContext(ctx)
// Track the actual served request for multipart cleanup, retaining the original transport body separately even if the handler replaces it.
Expand Down
12 changes: 6 additions & 6 deletions buffered.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import (
"github.com/aws/aws-lambda-go/events"
)

var errInformationalResponse = invocationError("response", ErrResponse, "informational response is unsupported", http.ErrNotSupported)
var errInformationalResponse = invocationError(OperationResponse, ErrResponse, "informational response is unsupported", http.ErrNotSupported)

// bufferedWriter belongs to one handler invocation.
// Only Header, WriteHeader and Write form its HTTP surface;
Expand Down Expand Up @@ -48,7 +48,7 @@ func (w *bufferedWriter) WriteHeader(status int) {
return
}
if status < 200 || status > 599 {
w.err = invocationError("response", ErrResponse, "invalid final response status")
w.err = invocationError(OperationResponse, ErrResponse, "invalid final response status")
return
}
w.status = status
Expand Down Expand Up @@ -79,19 +79,19 @@ func (w *bufferedWriter) Write(p []byte) (int, error) {
return 0, http.ErrBodyNotAllowed
}
if w.hasLength && int64(len(p)) > w.declaredLength-w.written {
w.err = invocationError("response", ErrResponse, "response exceeds declared content length", http.ErrContentLength)
w.err = invocationError(OperationResponse, ErrResponse, "response exceeds declared content length", http.ErrContentLength)
return 0, w.err
}
if int64(len(p)) > math.MaxInt64-w.written {
w.err = limitError("response", "buffered_body", math.MaxInt64, errResponseTooLarge)
w.err = limitError(OperationResponse, ResourceBufferedBody, math.MaxInt64, errResponseTooLarge)
return 0, w.err
}
data := p
if w.method == http.MethodHead {
data = p[:min(len(p), 512-len(w.body.data))]
}
if _, err := w.body.Write(data); err != nil {
w.err = limitError("response", "buffered_body", maxResponseBytes, errResponseTooLarge)
w.err = limitError(OperationResponse, ResourceBufferedBody, maxResponseBytes, errResponseTooLarge)
return 0, w.err
}
w.written += int64(len(p))
Expand All @@ -116,7 +116,7 @@ func (w *bufferedWriter) finish() (*bufferedResponse, error) {
return nil, err
}
if w.method != http.MethodHead && w.hasLength && w.written != w.declaredLength {
return nil, invocationError("response", ErrResponse, "response body length mismatch", http.ErrContentLength)
return nil, invocationError(OperationResponse, ErrResponse, "response body length mismatch", http.ErrContentLength)
}
if responseBodyAllowed(w.status) {
if len(w.body.data) > 0 && w.headers.canSniffType() {
Expand Down
28 changes: 14 additions & 14 deletions decode.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,22 +28,22 @@ func decodeEvent(payload []byte) (decodedEvent, error) {
if err := json.Unmarshal(payload, &envelope); err != nil {
// JSON errors can contain object names and input values.
// Do not expose them, even through an unwrap chain, at this credential-bearing boundary.
return decodedEvent{}, invocationError("decode", ErrInvalidEvent, "invalid event JSON")
return decodedEvent{}, invocationError(OperationDecode, ErrInvalidEvent, "invalid event JSON")
}
if envelope == nil {
return decodedEvent{}, invocationError("decode", ErrInvalidEvent, "event must be a JSON object")
return decodedEvent{}, invocationError(OperationDecode, ErrInvalidEvent, "event must be a JSON object")
}
var version string
if len(envelope.Version) != 0 {
if err := json.Unmarshal(envelope.Version, &version); err != nil {
return decodedEvent{}, invocationError("decode", ErrInvalidEvent, "invalid payload version")
return decodedEvent{}, invocationError(OperationDecode, ErrInvalidEvent, "invalid payload version")
}
if version != "1.0" && version != "2.0" {
return decodedEvent{}, invocationError("decode", ErrUnsupportedEvent, "unsupported payload version")
return decodedEvent{}, invocationError(OperationDecode, ErrUnsupportedEvent, "unsupported payload version")
}
}
if len(envelope.IsBase64Encoded) != 0 && envelope.IsBase64Encoded.Kind() != 't' && envelope.IsBase64Encoded.Kind() != 'f' {
return decodedEvent{}, invocationError("decode", ErrInvalidEvent, "isBase64Encoded must be a boolean")
return decodedEvent{}, invocationError(OperationDecode, ErrInvalidEvent, "isBase64Encoded must be a boolean")
}

if version == "2.0" {
Expand All @@ -55,7 +55,7 @@ func decodeEvent(payload []byte) (decodedEvent, error) {
} `json:"requestContext"`
}
if err := json.Unmarshal(payload, &wire, eventStringOptions); err != nil {
return decodedEvent{}, invocationError("decode", ErrInvalidEvent, "invalid v2 event fields")
return decodedEvent{}, invocationError(OperationDecode, ErrInvalidEvent, "invalid v2 event fields")
}
event := wire.APIGatewayV2HTTPRequest
event.RequestContext = wire.RequestContext.APIGatewayV2HTTPRequestContext
Expand All @@ -73,7 +73,7 @@ func decodeEvent(payload []byte) (decodedEvent, error) {
} `json:"requestContext"`
}
if err := json.Unmarshal(payload, &wire, eventStringOptions); err != nil {
return decodedEvent{}, invocationError("decode", ErrInvalidEvent, "invalid v1 event fields")
return decodedEvent{}, invocationError(OperationDecode, ErrInvalidEvent, "invalid v1 event fields")
}
event := wire.APIGatewayProxyRequest
event.RequestContext = wire.RequestContext.APIGatewayProxyRequestContext
Expand All @@ -85,29 +85,29 @@ func decodeEvent(payload []byte) (decodedEvent, error) {

func validateV1(event events.APIGatewayProxyRequest) error {
if event.RequestContext.APIID == "" {
return invocationError("validate", ErrInvalidEvent, "missing requestContext.apiId")
return invocationError(OperationValidate, ErrInvalidEvent, "missing requestContext.apiId")
}
if event.HTTPMethod == "" {
return invocationError("validate", ErrInvalidEvent, "missing httpMethod")
return invocationError(OperationValidate, ErrInvalidEvent, "missing httpMethod")
}
if event.Path == "" {
return invocationError("validate", ErrInvalidEvent, "missing path")
return invocationError(OperationValidate, ErrInvalidEvent, "missing path")
}
return nil
}

func validateV2(event events.APIGatewayV2HTTPRequest) error {
if event.Version != "" && event.Version != "2.0" {
return invocationError("validate", ErrInvalidEvent, "conflicting v2 payload version")
return invocationError(OperationValidate, ErrInvalidEvent, "conflicting v2 payload version")
}
if event.RequestContext.APIID == "" {
return invocationError("validate", ErrInvalidEvent, "missing requestContext.apiId")
return invocationError(OperationValidate, ErrInvalidEvent, "missing requestContext.apiId")
}
if event.RequestContext.HTTP.Method == "" {
return invocationError("validate", ErrInvalidEvent, "missing requestContext.http.method")
return invocationError(OperationValidate, ErrInvalidEvent, "missing requestContext.http.method")
}
if event.RawPath == "" {
return invocationError("validate", ErrInvalidEvent, "missing rawPath")
return invocationError(OperationValidate, ErrInvalidEvent, "missing rawPath")
}
return nil
}
Expand Down
2 changes: 1 addition & 1 deletion docs/plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,7 @@ these are placement, naming, typing and documentation changes made before the fi
- [x] E1: move the action-header selector to the standard-library-only `actionheader` package as `Selector`/`NewSelector`.
- [x] E2: rename the moved sentinels to `ErrMissing`, `ErrAmbiguous` and `ErrInvalid`.
- [x] E3: rename `identity.WithCaller` to `identity.NewContext`.
- [ ] E4: type the `InvocationError` operation and limit names with exported constants.
- [x] E4: type the `InvocationError` operation and limit names with exported constants.
- [ ] E5: add `String` methods to the `identity` enumerations.
- [ ] E6: complete sentinel docs, doc links, callback parameter names and `Claim.Bool` result names.

Expand Down
59 changes: 42 additions & 17 deletions errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,23 +37,50 @@ var (
// Parent cancellation is returned as the standard context error when there is no operation or cleanup failure.
// Application panics propagated to the runtime are not InvocationErrors.
type InvocationError struct {
operation string
operation Operation
detail string
cause error
limit *invocationLimit
}

type invocationLimit struct {
name string
maximum int64
resource Resource
maximum int64
}

// Operation identifies the invocation stage that failed.
type Operation string

// Invocation stages reported by [InvocationError.Operation].
const (
OperationValidate Operation = "validate" // adapter, argument or event envelope validation
OperationDecode Operation = "decode" // event JSON decoding
OperationRequest Operation = "request" // HTTP request conversion
OperationIdentity Operation = "identity" // gateway identity construction or isolation
OperationResponse Operation = "response" // HTTP response capture
OperationEncode Operation = "encode" // response envelope or stream prefix encoding
OperationCleanup Operation = "cleanup" // invocation-owned request cleanup
OperationStream Operation = "stream" // stream lifecycle after handoff
)

// Resource names an adapter-enforced limit reported by [InvocationError.Limit].
type Resource string

// Resources whose limits the adapter enforces.
const (
ResourceResponseHeaders Resource = "response_headers" // weighted response header budget
ResourceBufferedBody Resource = "buffered_body" // buffered body, including base64 expansion and HEAD byte counts
ResourceBufferedEnvelope Resource = "buffered_envelope" // complete serialized buffered response
ResourceStreamMetadata Resource = "stream_metadata" // streaming metadata prefix
ResourceIdentityClaims Resource = "identity_claims" // weighted gateway identity claims allowance
)

// Error returns a sanitized description of the failed operation.
func (e *InvocationError) Error() string {
if e == nil || e.operation == "" {
return "edge: invocation failed"
}
return "edge: " + e.operation + ": " + e.detail
return "edge: " + string(e.operation) + ": " + e.detail
}

// Unwrap exposes the failure category and any explicitly preserved safe causes.
Expand All @@ -64,33 +91,31 @@ func (e *InvocationError) Unwrap() error {
return e.cause
}

// Operation identifies the failed operation: validate, decode, request, identity, response, encode, cleanup, or stream.
// Operation identifies the failed operation as one of the Operation constants.
// A zero error returns "".
func (e *InvocationError) Operation() string {
func (e *InvocationError) Operation() Operation {
if e == nil {
return ""
}
return e.operation
}

// Limit reports the resource name and its effective maximum in bytes for a limit failure.
// Names are response_headers, buffered_body, buffered_envelope, stream_metadata, and identity_claims.
// Limit reports the exhausted resource, as one of the Resource constants, and its effective maximum in bytes for a limit failure.
// A response header or identity claims budget measures the documented charge;
// raw claims also have a wire-length limit.
// Neither budget measures heap use.
// buffered_body covers base64 expansion and the representable HEAD byte count.
// Other failures return "", 0, false.
func (e *InvocationError) Limit() (name string, maximum int64, ok bool) {
func (e *InvocationError) Limit() (resource Resource, maximum int64, ok bool) {
if e == nil || e.limit == nil {
return "", 0, false
}
return e.limit.name, e.limit.maximum, true
return e.limit.resource, e.limit.maximum, true
}

// invocationError accepts only package-owned diagnostic strings and causes.
// Never pass parser errors, application errors, or input values here.
// Error() sanitization alone would not make an unsafe Unwrap chain safe.
func invocationError(operation string, category error, detail string, causes ...error) *InvocationError {
func invocationError(operation Operation, category error, detail string, causes ...error) *InvocationError {
cause := category
if len(causes) != 0 {
cause = errors.Join(append([]error{category}, causes...)...)
Expand All @@ -99,9 +124,9 @@ func invocationError(operation string, category error, detail string, causes ...
}

// limitError has the same safe-input requirements as invocationError.
func limitError(operation, name string, maximum int64, reason error) *InvocationError {
func limitError(operation Operation, resource Resource, maximum int64, reason error) *InvocationError {
err := invocationError(operation, ErrLimitExceeded, "resource limit exceeded", reason)
err.limit = &invocationLimit{name: name, maximum: maximum}
err.limit = &invocationLimit{resource: resource, maximum: maximum}
return err
}

Expand All @@ -110,10 +135,10 @@ func limitError(operation, name string, maximum int64, reason error) *Invocation
// Gateway preparation uses this boundary for raw and typed claim construction.
func claimsError(err error) *InvocationError {
if limit, ok := errors.AsType[*identity.ClaimsLimitError](err); ok {
return limitError("identity", "identity_claims", limit.Maximum(), errors.Join(ErrIdentity, identity.ErrClaimsLimit))
return limitError(OperationIdentity, ResourceIdentityClaims, limit.Maximum(), errors.Join(ErrIdentity, identity.ErrClaimsLimit))
}
if errors.Is(err, identity.ErrInvalidClaims) {
return invocationError("identity", ErrIdentity, "invalid claims", identity.ErrInvalidClaims)
return invocationError(OperationIdentity, ErrIdentity, "invalid claims", identity.ErrInvalidClaims)
}
return invocationError("identity", ErrIdentity, "claims construction failed")
return invocationError(OperationIdentity, ErrIdentity, "claims construction failed")
}
6 changes: 3 additions & 3 deletions errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ func TestInvocationErrorCategories(t *testing.T) {
name string
fail func(*testing.T) error
category error
operation string
operation Operation
standard error
}{
{name: "nil_context", category: ErrInvalidInvocation, operation: "validate", fail: func(t *testing.T) error {
Expand Down Expand Up @@ -129,8 +129,8 @@ func TestInvocationErrorLimits(t *testing.T) {
for _, tt := range []struct {
name string
fail func(*testing.T) error
operation string
limit string
operation Operation
limit Resource
maximum int64
}{
{name: "original_headers", operation: "response", limit: "response_headers", maximum: 32, fail: func(t *testing.T) error {
Expand Down
10 changes: 5 additions & 5 deletions gateway_identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@ func (c gatewayCandidates) caller() (identity.Caller, error) {
}
}
if count > 1 {
return identity.Caller{}, invocationError("identity", ErrIdentity, "conflicting gateway assertions", identity.ErrConflict)
return identity.Caller{}, invocationError(OperationIdentity, ErrIdentity, "conflicting gateway assertions", identity.ErrConflict)
}
if c.custom || c.unknown {
return identity.Caller{}, invocationError("identity", ErrIdentity, "unsupported gateway assertion", errors.ErrUnsupported)
return identity.Caller{}, invocationError(OperationIdentity, ErrIdentity, "unsupported gateway assertion", errors.ErrUnsupported)
}
var caller identity.Caller
var err error
Expand All @@ -45,7 +45,7 @@ func (c gatewayCandidates) caller() (identity.Caller, error) {
return identity.Caller{}, err // A package-owned preflight limit.
}
if errors.Is(err, identity.ErrInvalidCaller) && !errors.Is(err, identity.ErrClaimsLimit) {
return identity.Caller{}, invocationError("identity", ErrIdentity, "invalid gateway caller", identity.ErrInvalidCaller)
return identity.Caller{}, invocationError(OperationIdentity, ErrIdentity, "invalid gateway caller", identity.ErrInvalidCaller)
}
return identity.Caller{}, claimsError(err)
}
Expand Down Expand Up @@ -88,7 +88,7 @@ func nonnull(raw jsontext.Value) bool { return len(raw) != 0 && raw.Kind() != 'n
func rawGatewayCaller(event decodedEvent, budget int) (identity.Caller, error) {
fields, err := rawObject(event.authorizer)
if err != nil {
return identity.Caller{}, invocationError("identity", ErrIdentity, "invalid authorizer object", identity.ErrInvalidCaller)
return identity.Caller{}, invocationError(OperationIdentity, ErrIdentity, "invalid authorizer object", identity.ErrInvalidCaller)
}
var candidates gatewayCandidates
if event.v1 != nil {
Expand Down Expand Up @@ -193,7 +193,7 @@ func rawGatewayScopes(raw jsontext.Value, maximum int) ([]string, error) {

func gatewayScopeLimit(maximum int) error {
// Keep byte-limit metadata without constructing identity's opaque error type.
return limitError("identity", "identity_claims", int64(maximum), errors.Join(ErrIdentity, identity.ErrClaimsLimit))
return limitError(OperationIdentity, ResourceIdentityClaims, int64(maximum), errors.Join(ErrIdentity, identity.ErrClaimsLimit))
}

func typedGatewayCaller(event decodedEvent, budget int) (identity.Caller, error) {
Expand Down
8 changes: 4 additions & 4 deletions invocation.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,26 +28,26 @@ func (inv *invocation) ownRequest(r *http.Request) {
func (inv *invocation) cleanup(err error) error {
if inv.body != nil {
if closeErr := inv.body.Close(); closeErr != nil {
err = errors.Join(err, invocationError("cleanup", ErrCleanup, "request body cleanup failed"))
err = errors.Join(err, invocationError(OperationCleanup, ErrCleanup, "request body cleanup failed"))
}
}
if inv.request != nil && inv.request.MultipartForm != nil {
if removeErr := inv.request.MultipartForm.RemoveAll(); removeErr != nil {
err = errors.Join(err, invocationError("cleanup", ErrCleanup, "multipart cleanup failed"))
err = errors.Join(err, invocationError(OperationCleanup, ErrCleanup, "multipart cleanup failed"))
}
}
return err
}

func invocationContext(parent context.Context) (context.Context, context.CancelFunc, error) {
if parent == nil {
return nil, nil, invocationError("validate", ErrInvalidInvocation, "nil invocation context")
return nil, nil, invocationError(OperationValidate, ErrInvalidInvocation, "nil invocation context")
}
if err := parent.Err(); err != nil {
return nil, nil, err
}
if identity.FromContext(parent).Kind() != identity.KindAnonymous {
return nil, nil, invocationError("identity", ErrIdentity, "invocation context already has a caller", identity.ErrConflict)
return nil, nil, invocationError(OperationIdentity, ErrIdentity, "invocation context already has a caller", identity.ErrConflict)
}
ctx, cancel := context.WithCancel(parent)
return ctx, cancel, nil
Expand Down
Loading
Loading