-
Notifications
You must be signed in to change notification settings - Fork 5
lambda: restore ListStaticEntitlements tolerance and fix log-based failure classification #1073
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -33,12 +33,20 @@ const ( | |
| // FailureClassHandled means the function returned an error response through | ||
| // the runtime API rather than crashing. | ||
| FailureClassHandled = "handled" | ||
| // FailureClassUnsupportedRPC means the connector could not resolve the | ||
| // request message's type URL against its proto registry, which is how an | ||
| // SDK older than the RPC fails. The RPC is absent, not broken. | ||
| FailureClassUnsupportedRPC = "unsupported_rpc" | ||
| ) | ||
|
|
||
| // oomErrorType is the Error Type the AWS platform stamps on the REPORT line | ||
| // when it kills a sandbox for exceeding its memory limit. | ||
| const oomErrorType = "Runtime.OutOfMemory" | ||
|
|
||
| // protoResolveErrorType is the errorType a connector runtime reports when it | ||
| // cannot resolve a message's type URL against its proto registry. | ||
| const protoResolveErrorType = "prefixError" | ||
|
|
||
| // LambdaInvokeFailure is a structured description of a failed Lambda invoke. | ||
| // | ||
| // Every field is derived from data the invoke already returns: the base64 tail | ||
|
|
@@ -105,6 +113,13 @@ func (e *LambdaInvokeFailure) Code() codes.Code { | |
| return codes.DeadlineExceeded | ||
| case FailureClassOOM: | ||
| return codes.ResourceExhausted | ||
| case FailureClassUnsupportedRPC: | ||
| // The connector predates the RPC, so every invoke fails identically | ||
| // until it is rebuilt. Unimplemented lets a caller skip the step the | ||
| // same way it would for any connector that does not serve the method, | ||
| // and survives error-string sanitization, which a substring match on | ||
| // the log summary does not. | ||
| return codes.Unimplemented | ||
| default: | ||
| return codes.Unknown | ||
| } | ||
|
|
@@ -129,6 +144,8 @@ func (e *LambdaInvokeFailure) Error() string { | |
| } | ||
| case FailureClassTimeout: | ||
| _, _ = b.WriteString("function timed out") | ||
| case FailureClassUnsupportedRPC: | ||
| _, _ = b.WriteString("function does not support this RPC") | ||
| default: | ||
| _, _ = b.WriteString("function returned error") | ||
| } | ||
|
|
@@ -373,6 +390,15 @@ func extractMeaningfulLogLines(raw string) string { | |
| func classifyLambdaFailure(functionError string, statusCode int32, payload []byte, rawLog string) *LambdaInvokeFailure { | ||
| report, haveReport := parseLambdaReportLine(rawLog) | ||
| errPayload := parseLambdaErrorPayload(payload) | ||
|
|
||
| // Two different views of the same log, on purpose. | ||
| // | ||
| // LogSummary is the one that may reach a customer-visible field, so it | ||
| // keeps the truncation pre-filter that drops a partial leading line. | ||
| // Classification reads the unfiltered lines instead: dropping a line is a | ||
| // sanitization decision, and a sanitization decision must never be able to | ||
| // change which failure class an invoke lands in. | ||
| signalLogs := extractMeaningfulLogLines(rawLog) | ||
| filteredLogs := extractMeaningfulLogLines(dropTruncatedFirstLine(rawLog)) | ||
|
|
||
| failure := &LambdaInvokeFailure{ | ||
|
|
@@ -392,7 +418,15 @@ func classifyLambdaFailure(functionError string, statusCode int32, payload []byt | |
| if haveReport && report.ErrorType != "" { | ||
| failure.ErrorType = report.ErrorType | ||
| } | ||
| failure.FailureClass = lambdaFailureClass(functionError, payload, filteredLogs, report, failure.ErrorType) | ||
| failure.FailureClass = lambdaFailureClass( | ||
| functionError, | ||
| payload, | ||
| signalLogs, | ||
| report, | ||
| failure.ErrorType, | ||
| errPayload.ErrorType, | ||
| failure.ErrorMessage, | ||
| ) | ||
|
|
||
| return failure | ||
| } | ||
|
|
@@ -402,29 +436,59 @@ func classifyLambdaFailure(functionError string, statusCode int32, payload []byt | |
| // Timeout is checked first: a sandbox killed on its execution timeout can also | ||
| // show peak memory at its ceiling, which would otherwise trip the OOM | ||
| // memory-comparison fallback. | ||
| func lambdaFailureClass(functionError string, payload []byte, filteredLogs string, report lambdaReport, errorType string) string { | ||
| // | ||
| // errorType is the resolved type, where a REPORT line's platform verdict wins | ||
| // over the payload's. payloadErrorType is the function's own, always. The two | ||
| // are separate because the OOM signals want the platform's verdict while an | ||
| // absent RPC is a statement only the function can make: a REPORT line carrying | ||
| // any Error Type would otherwise overwrite it and lose the capability gap. | ||
| func lambdaFailureClass( | ||
| functionError string, | ||
| payload []byte, | ||
| signalLogs string, | ||
| report lambdaReport, | ||
| errorType string, | ||
| payloadErrorType string, | ||
| errorMessage string, | ||
| ) string { | ||
| // Existing signal, unchanged: the platform writes this into the error | ||
| // payload on a hard timeout kill. | ||
| if strings.Contains(string(payload), "Task timed out after") { | ||
| return FailureClassTimeout | ||
| } | ||
| // Existing signal, unchanged: the function's own context deadline was | ||
| // exhausted and the connector logged it. | ||
| if strings.Contains(filteredLogs, `\"error\":\"context deadline exceeded\"`) { | ||
| if strings.Contains(signalLogs, `\"error\":\"context deadline exceeded\"`) { | ||
| return FailureClassTimeout | ||
| } | ||
| // New signal: newer runtimes stamp the outcome on the REPORT line. | ||
| if strings.EqualFold(report.Status, "timeout") { | ||
| return FailureClassTimeout | ||
| } | ||
|
|
||
| // A connector whose SDK predates the RPC cannot resolve the request | ||
| // message's type URL. Checked before the OOM signals because it is an exact | ||
| // match on the runtime's own verdict, where the memory fallback below is | ||
| // inferred. | ||
| if isUnresolvedTypeURL(payloadErrorType, errorMessage, signalLogs) { | ||
| return FailureClassUnsupportedRPC | ||
| } | ||
|
Comment on lines
460
to
+475
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Suggestion (medium confidence): the in-function deadline signal is read from the tail log, which this file already documents as possibly containing a previous invoke's output on a warm sandbox (see |
||
|
|
||
| // Primary OOM signal, from the REPORT line or the error payload. | ||
| if errorType == oomErrorType { | ||
| return FailureClassOOM | ||
| } | ||
| // Fallback for runtimes that report the kill without an Error Type: a | ||
| // failed invoke whose peak memory reached its ceiling was an OOM. | ||
| if strings.EqualFold(report.Status, "error") && | ||
| // | ||
| // Gated on the error type not being the function's own. A process the | ||
| // platform killed reports either nothing or a Runtime.* type such as | ||
| // Runtime.ExitError ("signal: killed"), so the ceiling is the best | ||
| // explanation available. When the function surfaced its own error value it | ||
| // has already explained the failure, and peak memory sitting at the ceiling | ||
| // is a coincidence - a Go runtime routinely runs at its ceiling without | ||
| // being killed. | ||
| if !isFunctionErrorType(errorType) && strings.EqualFold(report.Status, "error") && | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Suggestion: this commit split the resolved and payload error types precisely because "a REPORT line carrying any Error Type would otherwise overwrite" the function's own verdict — but the OOM gate still reads the resolved |
||
| report.MemorySizeMB > 0 && report.MaxMemoryUsedMB >= report.MemorySizeMB { | ||
| return FailureClassOOM | ||
| } | ||
|
|
@@ -435,6 +499,38 @@ func lambdaFailureClass(functionError string, payload []byte, filteredLogs strin | |
| return FailureClassUnhandled | ||
| } | ||
|
|
||
| // platformErrorTypePrefix marks the error types the AWS runtime generates | ||
| // itself, such as Runtime.ExitError and Runtime.OutOfMemory. Anything without | ||
| // it came from the function's own error value. | ||
| const platformErrorTypePrefix = "Runtime." | ||
|
|
||
| // isFunctionErrorType reports whether the error type came from the function | ||
| // rather than the platform. An absent type is not a function error: a hard kill | ||
| // leaves nothing behind. | ||
| func isFunctionErrorType(errorType string) bool { | ||
| return errorType != "" && !strings.HasPrefix(errorType, platformErrorTypePrefix) | ||
| } | ||
|
|
||
| // isUnresolvedTypeURL reports whether the runtime failed to resolve a message's | ||
| // type URL against its proto registry. | ||
| // | ||
| // Both the error payload's message and the log lines are searched because which | ||
| // one carries the text depends on how the runtime surfaced the failure. The | ||
| // type URL itself is deliberately not matched: every RPC added after a given | ||
| // connector's SDK version fails this same way, so pinning the check to one | ||
| // method name would leave the next one unhandled. | ||
| func isUnresolvedTypeURL(errorType string, errorMessage string, signalLogs string) bool { | ||
| if errorType != protoResolveErrorType { | ||
| return false | ||
| } | ||
| for _, s := range []string{errorMessage, signalLogs} { | ||
| if strings.Contains(s, "unable to resolve") && strings.Contains(s, "type.googleapis.com/") { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Suggestion (medium confidence): the two |
||
| return true | ||
| } | ||
| } | ||
| return false | ||
| } | ||
|
|
||
| // lambdaLogTailTruncationThresholdBytes is the point at which a tail log may | ||
| // have been cut mid-line. AWS returns the last 4 KB of the execution log with | ||
| // LogType: Tail and cuts the window at a byte offset, not a line boundary, so a | ||
|
|
@@ -493,20 +589,42 @@ func looksLikeLogLineStart(line string) bool { | |
| return true | ||
| } | ||
| } | ||
| // A text-format runtime line, which the platform prefixes with an | ||
| // RFC3339 timestamp such as "2006-01-02T15:04:05.000Z". | ||
| // A text-format line, identified by the timestamp its writer puts in front. | ||
| return looksLikeTimestampPrefix(line) | ||
| } | ||
|
|
||
| // looksLikeTimestampPrefix reports whether a line opens with an RFC3339-style | ||
| // date, i.e. "NNNN-NN-NNT". | ||
| // timestampLayouts are the line-leading timestamp shapes that mark a whole log | ||
| // record, written with 'N' standing for any digit. | ||
| // | ||
| // Both entries matter. The AWS platform writes RFC3339, but the connector | ||
| // runtimes writing into this log use Go's standard logger, whose default prefix | ||
| // is "2006/01/02 15:04:05" - slashes and a space, not dashes and a "T". | ||
| // Recognising only RFC3339 classifies every one of those whole lines as a | ||
| // truncated fragment. | ||
| var timestampLayouts = []string{ | ||
| "NNNN-NN-NNT", | ||
| "NNNN/NN/NN NN:NN:NN", | ||
| } | ||
|
|
||
| // looksLikeTimestampPrefix reports whether a line opens with a recognised | ||
| // timestamp layout. | ||
| func looksLikeTimestampPrefix(line string) bool { | ||
| const stamp = "NNNN-NN-NNT" | ||
| if len(line) < len(stamp) { | ||
| for _, layout := range timestampLayouts { | ||
| if matchesDigitLayout(line, layout) { | ||
| return true | ||
| } | ||
| } | ||
| return false | ||
| } | ||
|
|
||
| // matchesDigitLayout reports whether line starts with layout, where 'N' matches | ||
| // any digit and every other byte must match exactly. | ||
| func matchesDigitLayout(line string, layout string) bool { | ||
| if len(line) < len(layout) { | ||
| return false | ||
| } | ||
| for i := range len(stamp) { | ||
| want := stamp[i] | ||
| for i := range len(layout) { | ||
| want := layout[i] | ||
| got := line[i] | ||
| switch want { | ||
| case 'N': | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Suggestion (low-medium confidence):
"prefixError"is the unexported struct name ofgoogle.golang.org/protobuf/internal/errors.prefixError(vendor/google.golang.org/protobuf/internal/errors/errors.go:24), surfaced only becauseaws-lambda-gostamps the Go type name intoerrorType. A rename or refactor in a routine protobuf-go bump silently reverts this whole fix to the original bug, with no compile error and no test failure. Worth noting the vendored source in the comment so a dependency bump has a chance of flagging it, and consider whether the"unable to resolve" + "type.googleapis.com/"text match alone is sufficient without the exact-type gate.