Skip to content

lambda: restore ListStaticEntitlements tolerance and fix log-based failure classification - #1073

Draft
johnallers wants to merge 2 commits into
mainfrom
jallers/lambda-failure-classification-fixes
Draft

lambda: restore ListStaticEntitlements tolerance and fix log-based failure classification#1073
johnallers wants to merge 2 commits into
mainfrom
jallers/lambda-failure-classification-fixes

Conversation

@johnallers

@johnallers johnallers commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

What broke

#1048 made every lambda transport error path emit both the lambda_transport: prefix and the logSummary: separator, so downstream sanitizers could strip connector log text out of customer-visible fields. That closed a real leak.

It also broke the tolerance added in #560 for connectors too old to serve ListStaticEntitlements. That check matched a substring of the connector's raw log text via err.Error() — precisely what a sanitizing caller now removes. Before #1048 the prefixError path returned bare log lines carrying neither marker, so it slipped past sanitization and the substring matched. After, it is sanitized to a generic message, the match fails, the error propagates, and the sync fails at that step instead of skipping it.

This affects every connector built against baton-sdk ≤ v0.5.21 — anything older than the release that introduced the RPC. Those runtimes cannot resolve EntitlementsServiceListStaticEntitlementsRequest and report errorType: prefixError.

Impact in production

This is live, not hypothetical. Measured across the rollout of the release that vendored #1048 into the invoking service:

  • The tolerance stopped firing entirely. Its ignoring prefixError log ran steadily at roughly 1.4k/day, then went to 0 in the same hour the rollout landed, and has stayed at 0 every day since.
  • The failures it had been absorbing reappeared as hard errors, at roughly 2.5k/day. Each one fails the static-entitlements step of a sync that previously skipped it.
  • The timeout misclassification below is also live, and independently costs affected invokes their retry-and-checkpoint.

The two counts are complements of each other: the same population moved from "tolerated, logged at info" to "propagated as a sync failure" at the release boundary, which is what identifies the cause as this change rather than any shift in connector behaviour.

The fix

Classify the failure instead of matching on it. A runtime that cannot resolve a request message's type URL is reporting a capability gap, not a crash. It now becomes FailureClassUnsupportedRPCcodes.Unimplemented, and the syncer keys off the code, which survives sanitization. The old substring stays as a fallback for transports that do not yet classify. The check is no longer pinned to one method name — every RPC added after a given connector's SDK version fails identically, so pinning it would leave the next one unhandled.

Two more classification defects from the same change

Sanitization could change a failure class. dropTruncatedFirstLine ran before the log-based signals. The in-function timeout marker ("error":"context deadline exceeded") vanished whenever it landed on the first line of a full tail window, downgrading a retryable DeadlineExceeded into a terminal Unknown and costing the sync framework its retry-and-checkpoint. Classification now reads the unfiltered lines; LogSummary keeps the filter, since dropping a line is a sanitization decision and must not be able to move a failure class.

looksLikeLogLineStart only recognised RFC3339. Connector runtimes log through Go's standard logger, whose default prefix is 2006/01/02 15:04:05 — slashes and a space, not dashes and a T. Every one of those whole lines was being treated as a truncated fragment and dropped on any tail ≥ 4 KB.

OOM memory fallback could false-positive. Now gated on the error type not being the function's own: a platform kill leaves nothing behind or a Runtime.* type, whereas a function that surfaced its own error value already explained the failure — and a Go runtime routinely sits at its ceiling without being killed.

Companion change required

This is inert on its own for callers that sanitize. mapInvokeError in the platform repo preserves only DeadlineExceeded; every other classification collapses to a generic infra error, so Unimplemented (and the ResourceExhausted that #1048 introduced for OOM) never reaches the sync framework. That repo also never recovers *LambdaInvokeFailure via errors.As, so the structured fields are only ever visible as substrings.

The companion change is now open and green. It routes on the typed failure via errors.As and returns the connector's own error text for the connector-authored case, which incidentally restores the substring fallback here as well — so the two changes are belt-and-braces on the tolerance and can land in either order. Note the ordering consequence: while that fallback is load-bearing, the consumer cannot truncate or reshape LogSummary without re-breaking the tolerance. Once this PR merges the syncer keys off codes.Unimplemented instead, and that constraint goes away.

Testing

make lint clean, ./pkg/sync/... ./pkg/lambda/... green. Each new test was validated as an instrument — reverted its fix and confirmed the test fails:

Test Guards
TestSyncToleratesUnimplementedStaticEntitlements the sync no longer fails; includes a reachability oracle proving the RPC is actually called, and an unrelated-error case proving other failures still fail
TestClassifyLambdaFailureTruncationDoesNotChangeClass timeout signal survives sanitization, both for a whole Go-format line and a genuine mid-record fragment
TestLooksLikeTimestampPrefix / TestDropTruncatedFirstLine Go stdlib log format recognised as a whole line
TestClassifyLambdaFailure (2 new cases) unresolved type URL → Unimplemented; function error type at the memory ceiling is not an OOM

Existing TestClassifyLambdaFailure/oom_from_memory_at_ceiling_without_error_type still passes — Runtime.ExitError / signal: killed at the ceiling remains a genuine OOM.

Independent re-verification of the two classification defects

Both were re-confirmed after the fact by running one identical throwaway test against main and against this branch. Defect 1 uses byte-identical log content in both rows — only the tail length differs, so tail size alone is what moves the class:

Defect 1 — tail size main this branch
311 bytes timeout / DeadlineExceeded timeout / DeadlineExceeded
4,224 bytes unhandled / Unknown timeout / DeadlineExceeded
Defect 2 — first line of a full tail main this branch
2006/01/02 15:04:05 (Go stdlib default) dropped as a fragment kept
2006-01-02T15:04:05Z (RFC3339) kept kept

Defect 2 is the mechanism behind Defect 1: the marker line carrying the in-function timeout signal is written by Go's standard logger, so it was the line being discarded.

…assification

#1048 gave every transport error path both the "lambda_transport:" prefix and
the "logSummary:" separator so downstream sanitizers could strip connector log
text from customer-visible fields. That closed a real leak, but the tolerance
for connectors too old to serve ListStaticEntitlements was reading the raw log
text out of err.Error() -- exactly what the sanitizer now removes. The
substring stopped matching, the error propagated, and every sync of such a
connector failed at that step instead of skipping it.

Classify the failure instead of matching on it. A runtime that cannot resolve
a request message's type URL reports errorType "prefixError"; that is a
capability gap, not a crash, so it becomes FailureClassUnsupportedRPC and maps
to codes.Unimplemented. The syncer keys off the code, which survives
sanitization. The old substring stays as a fallback for transports that do not
yet classify, and the check is no longer pinned to one method name -- every RPC
added after a given connector's SDK version fails the same way.

Two more classification defects from the same change:

- dropTruncatedFirstLine ran before the log-based signals, so a sanitization
  decision could change a failure class. The in-function timeout marker
  disappeared whenever it landed on the first line of a full tail window,
  downgrading a retryable DeadlineExceeded into a terminal Unknown.
  Classification now reads the unfiltered lines; the summary keeps the filter.

- looksLikeLogLineStart recognised RFC3339 but not "2006/01/02 15:04:05", the
  default prefix of Go's standard logger and therefore of nearly every line
  connector runtimes write. Whole lines were being dropped as fragments.

Also gate the OOM memory-comparison fallback on the error type not being the
function's own. A platform kill leaves nothing behind or a Runtime.* type; when
the function surfaced its own error value it already explained the failure, and
a Go runtime routinely sits at its memory ceiling without being killed.
Comment thread pkg/sync/syncer.go
Comment on lines +2141 to +2142
if status.Code(err) == codes.Unimplemented ||
strings.Contains(err.Error(), legacyUnresolvedStaticEntitlementsMarker) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion (medium confidence): keying on codes.Unimplemented is broader than the old marker and will now silently swallow Unimplemented from sources unrelated to the SDK-version gap — uhttp.GrpcCodeFromHTTPStatus maps HTTP 501 to codes.Unimplemented (pkg/uhttp/wrapper.go:254), and pkg/connectorbuilder returns Unimplemented for misconfiguration (e.g. missing account manager / provisioner). A connector whose upstream API answers 501 during ListStaticEntitlements would get its static entitlements silently dropped from the sync with only an Info log, instead of failing loudly. Consider also requiring the failure to look like a capability gap (e.g. errors.As to *lambdagrpc.LambdaInvokeFailure with FailureClass == FailureClassUnsupportedRPC, falling back to the code), or at least logging this at Warn.

return false
}
for _, s := range []string{errorMessage, signalLogs} {
if strings.Contains(s, "unable to resolve") && strings.Contains(s, "type.googleapis.com/") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion (medium confidence): the two strings.Contains calls are evaluated independently against the whole joined multi-line string, so "unable to resolve" on one log line and "type.googleapis.com/" on a completely unrelated line satisfy the check together. Given that a false positive here maps to codes.Unimplemented, and Unimplemented is silently tolerated in two places in the syncer (getResourceFromConnector returns nil, nil at pkg/sync/syncer.go:1409, and syncStaticEntitlementsForResourceType skips the step), a false positive turns a real failure into silently missing data. Consider scoping the match to errorMessage only, or requiring both substrings on the same line.

Comment on lines 438 to +453
// 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(errorType, errorMessage, signalLogs) {
return FailureClassUnsupportedRPC
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 parseLambdaReportLine), and switching it to signalLogs widens that window by no longer dropping the truncated leading line. It runs before isUnresolvedTypeURL, which reads the current invoke's own error payload. So an old connector whose warm tail happens to carry a stale \"error\":\"context deadline exceeded\" classifies as timeout/DeadlineExceeded, the Unimplemented mapping never happens, and the ListStaticEntitlements tolerance this PR restores does not apply. Payload-derived signals are stronger than tail-log-derived ones; consider running isUnresolvedTypeURL ahead of the log-based deadline check.


// protoResolveErrorType is the errorType a connector runtime reports when it
// cannot resolve a message's type URL against its proto registry.
const protoResolveErrorType = "prefixError"

Copy link
Copy Markdown
Contributor

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 of google.golang.org/protobuf/internal/errors.prefixError (vendor/google.golang.org/protobuf/internal/errors/errors.go:24), surfaced only because aws-lambda-go stamps the Go type name into errorType. 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.

Comment thread pkg/sync/syncer_test.go Outdated
Comment on lines +1840 to +1842
// failChildResourceMockConnector wraps a mockConnector and fails ListResources calls for child resources.
// staticEntitlementsErrorMockConnector fails ListStaticEntitlements with a
// caller-supplied error, standing in for a connector whose SDK predates the RPC.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: the new type was inserted directly under the pre-existing failChildResourceMockConnector doc comment with no blank line, so line 1840 now reads as part of staticEntitlementsErrorMockConnector's doc block and failChildResourceMockConnector (line 1856) is left undocumented. Move line 1840 back above type failChildResourceMockConnector.

@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

General PR Review: lambda: restore ListStaticEntitlements tolerance and fix log-based failure classification

Blocking Issues: 0 | Suggestions: 1 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base 19f401dc9767.
Review mode: incremental since 198231a
View review run

Review Summary

The new commit splits the resolved error type from the payload's own so a REPORT line's Error Type can no longer overwrite the function's prefixError and mask the capability gap, adds the permutation test for that exact shape, and reattaches the failChildResourceMockConnector doc comment — which addresses the two prior suggestions on failure_test.go:291 and syncer_test.go:1840. The full PR diff was re-scanned for security and correctness: no security issues, and no new blocking correctness issues. One new suggestion: the split was applied to isUnresolvedTypeURL but not to the OOM memory fallback's gate, which still reads the resolved type and so is still maskable in the same way.

Risk triage (per docs/BUG_CATCHING.md §2) — Silence: yes, a misclassification yields a well-formed wrong gRPC code, not a crash. Durability: yes, a wrongly-skipped static-entitlements step writes a c1z missing those entitlements. Uncontrolled dimensions: yes, classification depends on tail-window contents and warm-sandbox log reuse. Consumer distance: yes, the code is interpreted by the sync framework and by the companion platform change. Consequence: re-sync (rung 2). Verdict: HIGH — silent + durable. Review-blind class: absence (a skipped step leaves no failure) and error-path permutations. Instruments already in the diff: a table-driven permutation table in TestClassifyLambdaFailure, a reachability oracle in TestSyncToleratesUnimplementedStaticEntitlements, and revert-and-confirm validation per test as stated in the PR body — that is real coverage for this change's shape. The gap is one uncovered permutation, noted below. No grant-expansion, compaction, checkpoint-loop, or artifact-open-time paths are touched, so no cost-curve contract applies.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

  • pkg/lambda/grpc/failure.go:491 - the OOM memory fallback's new gate reads the resolved errorType, not payloadErrorType, so a REPORT line's Runtime.* type masks a function-authored error type at the memory ceiling — the same masking this commit fixed for isUnresolvedTypeURL. That permutation (function payload type + Runtime.* report type + at ceiling) is also absent from the table. Medium confidence: the PR body states Runtime.ExitError at the ceiling is intended to stay an OOM, so this may be a deliberate choice that is simply unpinned.
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.

## Suggestions

In `pkg/lambda/grpc/failure.go`:
- Around line 491: The OOM memory fallback is gated on `!isFunctionErrorType(errorType)`,
  where `errorType` is the resolved type (a REPORT line's `Error Type` wins over the
  payload's). That makes the gate maskable in exactly the way this commit just fixed for
  `isUnresolvedTypeURL`: when the payload carries a function-authored `errorType` (e.g.
  `prefixError`) and the REPORT line carries `Error Type: Runtime.ExitError` — the shape the
  new test case at failure_test.go:337 establishes as real — `isFunctionErrorType(errorType)`
  is false, so an invoke whose `Max Memory Used >= Memory Size` is classified
  `FailureClassOOM` / `codes.ResourceExhausted` with "function ran out of memory", even
  though the function already explained the failure with its own error value. Either change
  the gate to read `payloadErrorType` (matching the stated rationale that a
  function-surfaced error value means the ceiling proves nothing), or, if
  `Runtime.ExitError`-at-ceiling is deliberately meant to win regardless of the payload
  type, say so in the comment above the gate and add a table case covering the permutation
  `payload errorType: prefixError` + `REPORT Error Type: Runtime.ExitError` +
  `Max Memory Used == Memory Size` so the decision is pinned rather than incidental.
- Around lines 440-444: The doc comment says "payloadErrorType is the function's own,
  always." That is not accurate: on a hard platform kill the runtime writes the error
  payload itself, so `payloadErrorType` is `Runtime.ExitError` (see the existing
  "oom from report error type" and "oom from memory at ceiling without error type"
  fixtures). Reword to say payloadErrorType is the error payload's type, unresolved against
  the REPORT line — which is what the code actually passes and what
  `isUnresolvedTypeURL` needs.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

@johnallers
johnallers marked this pull request as draft August 10, 2026 22:54
Review follow-up.

classifyLambdaFailure resolves ErrorType with a REPORT line's platform verdict
winning over the payload's, and then handed that resolved value to the entire
classifier. But an absent RPC is a statement only the function can make, so a
REPORT line carrying any Error Type -- Runtime.ExitError, say -- overwrote the
payload's prefixError, isUnresolvedTypeURL stopped matching, and the invoke fell
through to unhandled/Unknown. That silently restores the bug this branch exists
to fix, with no test failure to show it.

Pass the payload's error type alongside the resolved one and use it for the
unresolved-type-URL check only. The OOM signals keep the resolved value, where
the platform's verdict is the right one to trust, and the ErrorType field keeps
reporting the platform's verdict because that is what the field means.

The shape seen in practice carries no REPORT Error Type for this failure, so
this closes a latent hole rather than a live one. The new case was validated as
an instrument: restoring the old argument fails it, and only it.

Also move a doc comment the previous commit orphaned --
failChildResourceMockConnector's godoc had ended up attached to the new
staticEntitlementsErrorMockConnector.
// 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") &&

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 errorType, so the same masking applies to it. The new test fixture on line 337 establishes that payload errorType: prefixError + REPORT Error Type: Runtime.ExitError is a real shape; with Max Memory Used >= Memory Size that shape makes isFunctionErrorType(errorType) false and the invoke is classified OOM (ResourceExhausted, "function ran out of memory") even though the function surfaced its own error value — exactly what the gate was added to prevent. Consider gating on !isFunctionErrorType(payloadErrorType), or at minimum add that permutation to the table so the choice is pinned. (Minor, same doc block: "payloadErrorType is the function's own, always" isn't quite true — the existing OOM fixtures carry the platform's Runtime.ExitError in the payload.)

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant