From c6d2cbe53329da9d1ed23a7e5ebd978db98d878a Mon Sep 17 00:00:00 2001 From: AJ Beck Date: Sun, 27 Sep 2026 15:30:56 +0100 Subject: [PATCH] refactor!: type InvocationError operation and resource names Add the Operation and Resource string types with exported constants for every documented invocation stage and adapter limit. InvocationError.Operation and Limit now return them, so callers compare against checked constants instead of copying literals from prose. Internal call sites use the same constants. Error text and values are unchanged. BREAKING CHANGE: InvocationError.Operation returns edge.Operation and InvocationError.Limit returns edge.Resource. Comparisons with untyped string literals still compile; code that assigns the results to string variables needs a conversion. --- adapter_invoke.go | 4 +-- buffered.go | 12 ++++----- decode.go | 28 ++++++++++----------- docs/plan.md | 2 +- errors.go | 59 +++++++++++++++++++++++++++++++------------- errors_test.go | 6 ++--- gateway_identity.go | 10 ++++---- invocation.go | 8 +++--- request.go | 32 ++++++++++++------------ response_encoding.go | 8 +++--- response_headers.go | 14 +++++------ stream.go | 8 +++--- stream_encoding.go | 6 ++--- streaming_adapter.go | 4 +-- streaming_writer.go | 10 ++++---- 15 files changed, 118 insertions(+), 93 deletions(-) diff --git a/adapter_invoke.go b/adapter_invoke.go index 80d5493..fb32253 100644 --- a/adapter_invoke.go +++ b/adapter_invoke.go @@ -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 } @@ -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. diff --git a/buffered.go b/buffered.go index 6ae367c..fdbb4ad 100644 --- a/buffered.go +++ b/buffered.go @@ -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; @@ -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 @@ -79,11 +79,11 @@ 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 @@ -91,7 +91,7 @@ func (w *bufferedWriter) Write(p []byte) (int, error) { 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)) @@ -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() { diff --git a/decode.go b/decode.go index eb31bd8..088a101 100644 --- a/decode.go +++ b/decode.go @@ -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" { @@ -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 @@ -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 @@ -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 } diff --git a/docs/plan.md b/docs/plan.md index 10b05f7..5de7711 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -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. diff --git a/errors.go b/errors.go index ac17cb7..723e880 100644 --- a/errors.go +++ b/errors.go @@ -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. @@ -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...)...) @@ -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 } @@ -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") } diff --git a/errors_test.go b/errors_test.go index 27d820d..5ed9d1e 100644 --- a/errors_test.go +++ b/errors_test.go @@ -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 { @@ -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 { diff --git a/gateway_identity.go b/gateway_identity.go index b966245..58b14ab 100644 --- a/gateway_identity.go +++ b/gateway_identity.go @@ -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 @@ -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) } @@ -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 { @@ -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) { diff --git a/invocation.go b/invocation.go index 99e15d4..f14181a 100644 --- a/invocation.go +++ b/invocation.go @@ -28,12 +28,12 @@ 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 @@ -41,13 +41,13 @@ func (inv *invocation) cleanup(err error) error { 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 diff --git a/request.go b/request.go index 9400b24..abeadee 100644 --- a/request.go +++ b/request.go @@ -21,7 +21,7 @@ func requestV1(ctx context.Context, event events.APIGatewayProxyRequest) (*http. return nil, err } if !strings.HasPrefix(event.Path, "/") { - return nil, invocationError("request", ErrInvalidEvent, "path must begin with a slash") + return nil, invocationError(OperationRequest, ErrInvalidEvent, "path must begin with a slash") } query := make(url.Values) for key, values := range event.MultiValueQueryStringParameters { @@ -52,22 +52,22 @@ func requestV2(ctx context.Context, event events.APIGatewayV2HTTPRequest) (*http return nil, err } if !strings.HasPrefix(event.RawPath, "/") { - return nil, invocationError("request", ErrInvalidEvent, "rawPath must begin with a slash") + return nil, invocationError(OperationRequest, ErrInvalidEvent, "rawPath must begin with a slash") } path, err := url.PathUnescape(event.RawPath) if err != nil { - return nil, invocationError("request", ErrInvalidEvent, "invalid rawPath encoding") + return nil, invocationError(OperationRequest, ErrInvalidEvent, "invalid rawPath encoding") } u := &url.URL{Path: path, RawPath: event.RawPath, RawQuery: event.RawQueryString} // EscapedPath ignores an invalid RawPath hint. // Reject rather than silently replacing raw delimiters, whitespace, or an invalid encoded spelling. if u.EscapedPath() != event.RawPath { - return nil, invocationError("request", ErrInvalidEvent, "invalid rawPath syntax") + return nil, invocationError(OperationRequest, ErrInvalidEvent, "invalid rawPath syntax") } // Query decoding remains the handler's job, but a request target cannot contain literal ASCII whitespace or control bytes on an HTTP request line. for i := 0; i < len(event.RawQueryString); i++ { if event.RawQueryString[i] <= ' ' || event.RawQueryString[i] == 0x7f { - return nil, invocationError("request", ErrInvalidEvent, "invalid raw query syntax") + return nil, invocationError(OperationRequest, ErrInvalidEvent, "invalid raw query syntax") } } header, err := requestHeaders(event.Headers, nil) @@ -77,7 +77,7 @@ func requestV2(ctx context.Context, event events.APIGatewayV2HTTPRequest) (*http if len(event.Cookies) != 0 { cookie := strings.Join(event.Cookies, "; ") if !validHeaderValue(cookie) { - return nil, invocationError("request", ErrInvalidEvent, "invalid cookie header") + return nil, invocationError(OperationRequest, ErrInvalidEvent, "invalid cookie header") } header.Set("Cookie", cookie) } @@ -95,12 +95,12 @@ func requestHeaders(single map[string]string, multi map[string][]string) (http.H header := make(http.Header) for _, key := range slices.Sorted(maps.Keys(multi)) { if !validToken(key) { - return nil, invocationError("request", ErrInvalidEvent, "invalid header name") + return nil, invocationError(OperationRequest, ErrInvalidEvent, "invalid header name") } name := http.CanonicalHeaderKey(key) for _, value := range multi[key] { if !validHeaderValue(value) { - return nil, invocationError("request", ErrInvalidEvent, "invalid header value") + return nil, invocationError(OperationRequest, ErrInvalidEvent, "invalid header value") } header[name] = append(header[name], value) } @@ -110,11 +110,11 @@ func requestHeaders(single map[string]string, multi map[string][]string) (http.H multiValues := header.Clone() for _, key := range slices.Sorted(maps.Keys(single)) { if !validToken(key) { - return nil, invocationError("request", ErrInvalidEvent, "invalid header name") + return nil, invocationError(OperationRequest, ErrInvalidEvent, "invalid header name") } value := single[key] if !validHeaderValue(value) { - return nil, invocationError("request", ErrInvalidEvent, "invalid header value") + return nil, invocationError(OperationRequest, ErrInvalidEvent, "invalid header value") } name := http.CanonicalHeaderKey(key) if !slices.Contains(multiValues[name], value) { @@ -126,21 +126,21 @@ func requestHeaders(single map[string]string, multi map[string][]string) (http.H func newRequest(ctx context.Context, r *http.Request, body string, binary bool) (*http.Request, error) { if ctx == nil { - return nil, invocationError("validate", ErrInvalidInvocation, "nil invocation context") + return nil, invocationError(OperationValidate, ErrInvalidInvocation, "nil invocation context") } if !validToken(r.Method) { - return nil, invocationError("request", ErrInvalidEvent, "invalid HTTP method") + return nil, invocationError(OperationRequest, ErrInvalidEvent, "invalid HTTP method") } host := r.Host header := r.Header if hosts, present := header["Host"]; present { if len(hosts) != 1 || hosts[0] == "" { - return nil, invocationError("request", ErrInvalidEvent, "invalid Host header") + return nil, invocationError(OperationRequest, ErrInvalidEvent, "invalid Host header") } host = hosts[0] } if host != "" && !validAuthority(host) { - return nil, invocationError("request", ErrInvalidEvent, "invalid request host") + return nil, invocationError(OperationRequest, ErrInvalidEvent, "invalid request host") } delete(header, "Host") if r.Proto == "" { @@ -148,14 +148,14 @@ func newRequest(ctx context.Context, r *http.Request, body string, binary bool) } major, minor, ok := http.ParseHTTPVersion(r.Proto) if !ok { - return nil, invocationError("request", ErrInvalidEvent, "invalid HTTP protocol") + return nil, invocationError(OperationRequest, ErrInvalidEvent, "invalid HTTP protocol") } var data []byte if binary { var err error data, err = base64.StdEncoding.DecodeString(body) if err != nil { - return nil, invocationError("request", ErrInvalidEvent, "invalid base64 request body") + return nil, invocationError(OperationRequest, ErrInvalidEvent, "invalid base64 request body") } } else { data = []byte(body) diff --git a/response_encoding.go b/response_encoding.go index ae4bd32..9047138 100644 --- a/response_encoding.go +++ b/response_encoding.go @@ -22,11 +22,11 @@ func marshalResponse(response any) ([]byte, error) { var out responseBuffer if err := json.MarshalWrite(&out, response); err != nil { if errors.Is(err, errResponseTooLarge) { - return nil, limitError("encode", "buffered_envelope", maxResponseBytes, errResponseTooLarge) + return nil, limitError(OperationEncode, ResourceBufferedEnvelope, maxResponseBytes, errResponseTooLarge) } // Codec errors can include application values. // Do not expose that chain. - return nil, invocationError("encode", ErrResponse, "response JSON encoding failed") + return nil, invocationError(OperationEncode, ErrResponse, "response JSON encoding failed") } return out.data, nil } @@ -63,7 +63,7 @@ func (b *responseBuffer) Write(p []byte) (int, error) { // It changes only the gateway envelope representation, never the content bytes. func encodeResponseBody(body []byte, header http.Header) (string, bool, error) { if len(body) > maxResponseBytes { - return "", false, limitError("response", "buffered_body", maxResponseBytes, errResponseTooLarge) + return "", false, limitError(OperationResponse, ResourceBufferedBody, maxResponseBytes, errResponseTooLarge) } if len(body) == 0 { return "", false, nil @@ -72,7 +72,7 @@ func encodeResponseBody(body []byte, header http.Header) (string, bool, error) { return string(body), false, nil } if base64.StdEncoding.EncodedLen(len(body)) > maxResponseBytes { - return "", false, limitError("response", "buffered_body", maxResponseBytes, errResponseTooLarge) + return "", false, limitError(OperationResponse, ResourceBufferedBody, maxResponseBytes, errResponseTooLarge) } return base64.StdEncoding.EncodeToString(body), true, nil } diff --git a/response_headers.go b/response_headers.go index 103ffc0..db4a832 100644 --- a/response_headers.go +++ b/response_headers.go @@ -13,10 +13,10 @@ const defaultResponseHeaderBudget = 256 * 1024 var ( errResponseHeaderBudget = errors.New("edge: response header budget exceeded") - errResponseHeaders = invocationError("response", ErrResponse, "invalid response headers") - errResponseTrailers = invocationError("response", ErrResponse, "response trailers are unsupported", http.ErrNotSupported) - errResponseUpgrade = invocationError("response", ErrResponse, "response upgrade is unsupported", http.ErrNotSupported) - errResponseHeaderRepeat = invocationError("response", ErrResponse, "repeated response field cannot be represented in payload 2.0") + errResponseHeaders = invocationError(OperationResponse, ErrResponse, "invalid response headers") + errResponseTrailers = invocationError(OperationResponse, ErrResponse, "response trailers are unsupported", http.ErrNotSupported) + errResponseUpgrade = invocationError(OperationResponse, ErrResponse, "response upgrade is unsupported", http.ErrNotSupported) + errResponseHeaderRepeat = invocationError(OperationResponse, ErrResponse, "repeated response field cannot be represented in payload 2.0") ) // responseHeaders owns its map and value slices. @@ -79,11 +79,11 @@ func chargeResponseHeaders(fields http.Header, remaining int) (int, error) { func snapshotResponseHeaders(fields http.Header, budget int) (*responseHeaders, error) { if budget <= 0 || budget > maxResponseBytes { - return nil, invocationError("validate", ErrInvalidInvocation, "invalid response header budget") + return nil, invocationError(OperationValidate, ErrInvalidInvocation, "invalid response header budget") } remaining, err := chargeResponseHeaders(fields, budget) if err != nil { - return nil, limitError("response", "response_headers", int64(budget), errResponseHeaderBudget) + return nil, limitError(OperationResponse, ResourceResponseHeaders, int64(budget), errResponseHeaderBudget) } keys := make([]string, 0, len(fields)) for name, values := range fields { @@ -167,7 +167,7 @@ func (h *responseHeaders) automatic(name, value string) error { } remaining, err := chargeResponseHeaders(http.Header{name: {value}}, h.remaining) if err != nil { - return limitError("response", "response_headers", int64(h.budget), errResponseHeaderBudget) + return limitError(OperationResponse, ResourceResponseHeaders, int64(h.budget), errResponseHeaderBudget) } h.fields[name] = []string{value} h.remaining = remaining diff --git a/stream.go b/stream.go index 2a50177..8bb68d7 100644 --- a/stream.go +++ b/stream.go @@ -9,9 +9,9 @@ import ( ) var ( - errStreamPanic = invocationError("stream", ErrStream, "stream producer panicked") - errStreamReporter = invocationError("stream", ErrStream, "stream error reporter panicked") - errStreamUnpublished = invocationError("stream", ErrStream, "stream producer ended without publishing a response") + errStreamPanic = invocationError(OperationStream, ErrStream, "stream producer panicked") + errStreamReporter = invocationError(OperationStream, ErrStream, "stream error reporter panicked") + errStreamUnpublished = invocationError(OperationStream, ErrStream, "stream producer ended without publishing a response") ) // streamOutput belongs to the producer. @@ -256,5 +256,5 @@ func (*responseStream) ContentType() string { // MarshalJSON deliberately declines serialization so the SDK selects its io.Reader response path. // Edge's framing codec uses JSON v2 separately. func (*responseStream) MarshalJSON() ([]byte, error) { - return nil, invocationError("encode", ErrStream, "streaming response cannot be marshaled as JSON") + return nil, invocationError(OperationEncode, ErrStream, "streaming response cannot be marshaled as JSON") } diff --git a/stream_encoding.go b/stream_encoding.go index b761897..4419cb2 100644 --- a/stream_encoding.go +++ b/stream_encoding.go @@ -13,7 +13,7 @@ var errStreamPrefixTooLarge = errors.New("edge: response metadata exceeds stream func encodeStreamPrefix(status int, headers *responseHeaders) ([]byte, error) { if status < 200 || status > 599 { - return nil, invocationError("response", ErrResponse, "invalid final response status") + return nil, invocationError(OperationResponse, ErrResponse, "invalid final response status") } metadata := struct { StatusCode int `json:"statusCode"` @@ -22,9 +22,9 @@ func encodeStreamPrefix(status int, headers *responseHeaders) ([]byte, error) { out := responseBuffer{limit: maxStreamPrefixBytes - 8} if err := json.MarshalWrite(&out, metadata); err != nil { if errors.Is(err, errResponseTooLarge) { - return nil, limitError("encode", "stream_metadata", maxStreamPrefixBytes, errStreamPrefixTooLarge) + return nil, limitError(OperationEncode, ResourceStreamMetadata, maxStreamPrefixBytes, errStreamPrefixTooLarge) } - return nil, invocationError("encode", ErrResponse, "streaming metadata JSON encoding failed") + return nil, invocationError(OperationEncode, ErrResponse, "streaming metadata JSON encoding failed") } // Allocate exactly the framed length. // The final zeroed bytes are the delimiter. diff --git a/streaming_adapter.go b/streaming_adapter.go index c956886..1905c47 100644 --- a/streaming_adapter.go +++ b/streaming_adapter.go @@ -79,7 +79,7 @@ func (a *StreamingAdapter) Handle(ctx context.Context, event jsontext.Value) (io return decodedEvent{}, err } if decoded.version != "" || decoded.v1 == nil { - return decodedEvent{}, invocationError("validate", ErrUnsupportedEvent, "streaming requires a REST proxy event") + return decodedEvent{}, invocationError(OperationValidate, ErrUnsupportedEvent, "streaming requires a REST proxy event") } return decoded, nil }, true) @@ -102,7 +102,7 @@ func (a *StreamingAdapter) HandleV1(ctx context.Context, event events.APIGateway func (a *StreamingAdapter) valid() error { if a == nil || a.handler == nil { - return invocationError("validate", ErrInvalidInvocation, "uninitialized streaming adapter") + return invocationError(OperationValidate, ErrInvalidInvocation, "uninitialized streaming adapter") } return nil } diff --git a/streaming_writer.go b/streaming_writer.go index 636c884..f60fab5 100644 --- a/streaming_writer.go +++ b/streaming_writer.go @@ -43,7 +43,7 @@ func (w *streamingWriter) 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 @@ -81,11 +81,11 @@ func (w *streamingWriter) 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 = invocationError("response", ErrResponse, "response byte count overflow") + w.err = invocationError(OperationResponse, ErrResponse, "response byte count overflow") return 0, w.err } if w.method == http.MethodHead { @@ -165,7 +165,7 @@ func (w *streamingWriter) writeBody(p []byte) (int, error) { case err == io.ErrClosedPipe: w.err = err // The bridge translates its own interruption safely. default: - w.err = invocationError("stream", ErrStream, "response body write failed") + w.err = invocationError(OperationStream, ErrStream, "response body write failed") } } return n, w.err @@ -183,7 +183,7 @@ func (w *streamingWriter) finish() error { return w.err } if w.method != http.MethodHead && w.hasLength && w.written != w.declaredLength { - w.err = invocationError("response", ErrResponse, "response body length mismatch", http.ErrContentLength) + w.err = invocationError(OperationResponse, ErrResponse, "response body length mismatch", http.ErrContentLength) return w.err } return w.publish()