Skip to content

Keep a dataless 2xx from FindApplicationKeyOwner retryable - #42

Open
c1-squire-dev[bot] wants to merge 2 commits into
mainfrom
highb/fix-owner-unknown-nil-data
Open

Keep a dataless 2xx from FindApplicationKeyOwner retryable#42
c1-squire-dev[bot] wants to merge 2 commits into
mainfrom
highb/fix-owner-unknown-nil-data

Conversation

@c1-squire-dev

@c1-squire-dev c1-squire-dev Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Summary

FindApplicationKeyOwner treated response.Data == nil as one of the two genuinely ownerless response shapes, joining ErrApplicationKeyOwnerUnknown onto it. It is not one: it is a transport anomaly, and joining the sentinel made it permanent.

This splits the nil-Data case into its own branch ahead of the sentinel guard, returning a plain wrapped error with no sentinel joined.

The failure mode

Which 2xx bodies reach it. GetApplicationKey returns HTTP 200 with a body that has no data key — most simply {}, or an error envelope substituted by an intermediary proxy.

Why the generated type returns nil error with nil Data. ApplicationKeyResponse.UnmarshalJSON in the vendored Datadog client decodes into an anonymous struct and only assigns o.Data from the payload's data key. When that key is absent it sets o.Data = nil and returns a nil error — the absence of data is not treated as a failure. So the 200 arrives at the connector as err == nil, response.Data == nil.

What it cost. Both Delete call sites in pkg/connector/application_key.go branch on the sentinel with errors.Is(err, client.ErrApplicationKeyOwnerUnknown). That branch returned a permanent codes.InvalidArgument:

the owning service account for application key %q could not be determined: ...

A retryable transport failure became a terminal refusal, and a live Datadog application key was left un-revokable — no amount of retrying could clear it, because the client had already decided the provider had answered.

The two remaining conditions in the sentinel guard — Relationships == nil and OwnedBy == nil — stay as they are. Those really are the provider answering successfully and naming no owner, which no retry changes.

Test

TestApplicationKeyBuilderDeleteKeepsDatalessSuccessBodyRetryable serves HTTP 200 with a syntactically valid but dataless body ({}) from the GetApplicationKey endpoint and asserts the error from Delete is neither codes.InvalidArgument nor errors.Is(err, client.ErrApplicationKeyOwnerUnknown).

The existing sibling TestApplicationKeyBuilderDeleteKeepsUnmappedTransportFailureRetryable cannot catch this: its response body is syntactically invalid JSON, so Unmarshal errors and the lookup exits through wrapOfficialClientError before ever reaching the Data == nil branch, landing on codes.Unknown. The new test is what reaches it.

Discrimination proof (test added first, then client change reverted to confirm it fails):

result
client.go change reverted ❌ FAIL — Should not be: 0x3 (codes.InvalidArgument) on the "a transport failure must not be converted into a permanent refusal" assertion
change applied ✅ PASS

Note on the second assertion: the NotErrorIs check passes in both directions because Delete's sentinel arm formats the cause with %v rather than chaining it — the same reason the neighbouring ownerless test asserts the sentinel at the producer. codes.InvalidArgument is the discriminating assertion, and it flips as shown above.

Gates

  • go build ./... — clean
  • go vet ./... — clean
  • go test ./... -count=1 — all packages pass
  • golangci-lint run — 0 issues

Only pkg/client/client.go and pkg/connector/credential_lifecycle_test.go change; go.mod, go.sum, vendor/, and .versions.yaml are untouched.

FindApplicationKeyOwner joined ErrApplicationKeyOwnerUnknown onto any
response whose Data was nil, alongside the two genuinely ownerless
shapes. But Data == nil is not the provider answering and naming no
owner: the generated ApplicationKeyResponse.UnmarshalJSON decodes into
an anonymous struct and only assigns o.Data from the payload's data key,
returning a nil error when a 200 body simply has no data. A 200 carrying
{}, or a proxy's error envelope, therefore arrives as err == nil with
Data == nil -- a transport anomaly, not a provider answer.

Both Delete call sites in pkg/connector/application_key.go branch on the
sentinel with errors.Is, so that anomaly was converted into a permanent
codes.InvalidArgument refusal. A retryable failure became terminal, and a
live Datadog application key was left un-revokable.

Split the nil-Data case into its own branch ahead of the sentinel guard,
returning a plain wrapped error with no sentinel joined. It falls
through Delete's default arm, keeping its code and staying retryable.

TestApplicationKeyBuilderDeleteKeepsDatalessSuccessBodyRetryable serves
a 200 with a syntactically valid but dataless body. The existing sibling
cannot reach this branch -- its body is syntactically invalid JSON, so
Unmarshal errors and the lookup exits through wrapOfficialClientError
before the guard, landing on codes.Unknown. With this change reverted
the new test fails on codes.InvalidArgument; with it applied it passes.

Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
Comment on lines +684 to +685
require.NotErrorIs(t, err, client.ErrApplicationKeyOwnerUnknown,
"a dataless 2xx is not the provider naming no owner")

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): this NotErrorIs assertion is vacuous — Delete's sentinel arm formats the cause with %v (application_key.go:106), so the sentinel never chains into the returned error and this passes even with the client.go fix reverted. Only the NotEqual(codes.InvalidArgument) check below is discriminating. The sibling ownerless test at line 611-615 already establishes the convention for this: assert at the producer, e.g. _, ownerErr := newLifecycleTestWrapper(server.URL).FindApplicationKeyOwner(context.Background(), "appkey-dataless-1"); require.NotErrorIs(t, ownerErr, client.ErrApplicationKeyOwnerUnknown).

@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Connector PR Review: Keep a dataless 2xx from FindApplicationKeyOwner retryable

Blocking Issues: 0 | Suggestions: 0 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base 2a93dd0891eb.
Review mode: incremental since bf35282
Review run: https://github.com/ConductorOne/baton-datadog/actions/runs/33442894859

Review Summary

The full PR diff was scanned for security and correctness: the pkg/client/client.go change splits a dataless 2xx out of the ErrApplicationKeyOwnerUnknown join so it keeps the retryable default arm of applicationKeyBuilder.Delete instead of collapsing into a permanent InvalidArgument refusal, which is correct for both FindApplicationKeyOwner call sites in pkg/connector/application_key.go. The new commit addresses the prior finding: TestApplicationKeyBuilderDeleteKeepsDatalessSuccessBodyRetryable now asserts NotErrorIs against the sentinel at the producer, pkg/connector/credential_lifecycle_test.go:698-701, rather than on the return value of Delete, matching the sibling test at line 614, so the assertion can actually fail if the client bug is reintroduced. No new issues found.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

None.

@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.

The NotErrorIs assertion on Delete's return in
TestApplicationKeyBuilderDeleteKeepsDatalessSuccessBodyRetryable could
not fail. applicationKeyBuilder.Delete's sentinel arm builds its error
with status.Errorf and a %v verb for the cause, which does not wrap it,
so client.ErrApplicationKeyOwnerUnknown is unreachable through errors.Is
from Delete -- the assertion passed even with the client-side bug
present, for the wrong reason.

Assert the sentinel where it is produced instead, following the
convention the sibling ownerless test already uses: FindApplicationKeyOwner
is called directly and its error is checked there, where the sentinel
really is in the chain. Delete's return keeps the assertions it can
actually carry -- the error itself and the surviving gRPC code, whose
InvalidArgument flip is what discriminates the fix.

Hoist the app key id into a const shared by both calls, and note in the
doc comment why the sentinel is asserted at the producer.

With the client.go fix reverted and the NotEqual assertion masked, the
producer-level NotErrorIs fails on its own -- the sentinel is found in
the chain. With the fix applied, both assertions pass.

Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>

@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