Skip to content

fix(appplatform): retry transient referential-integrity and server errors - #2838

Merged
rknightion merged 4 commits into
mainfrom
fix/provisioning-delete-read-retries
Jun 19, 2026
Merged

fix(appplatform): retry transient referential-integrity and server errors#2838
rknightion merged 4 commits into
mainfrom
fix/provisioning-delete-read-retries

Conversation

@rknightion

@rknightion rknightion commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Summary

App Platform resources only retried HTTP 409 conflicts on delete, and did not retry reads at all. Against the provisioning (Git Sync) API this surfaces as two intermittent, eventual-consistency failures:

  1. Destroy 422 — cross-resource referential-integrity race. When a connection and a repository that references it (spec.connection.name) are destroyed together, Terraform deletes the repository first. The repository DELETE returns 200 and stamps a deletionTimestamp, but the object lingers in storage until its finalizers complete.

    The connection delete validator decides at admission via a live ListByConnection (field selector spec.connection.name). With fix(provisioning): ignore terminating repositories when validating connection delete grafana#126822 (merged) the validator now ignores repositories that are themselves terminating (DeletionTimestamp != nil), which reduces the previously-persistent 422 to a narrow storage read-after-write window: if the validator's list has not yet observed the repository's freshly-written deletionTimestamp (~100ms; a field-selector list may be served from the eventual-consistent index path rather than a quorum read), it still counts the repository as a live reference and returns:

    Error: invalid value for field "metadata.name":
      * Forbidden: cannot delete connection: referenced by 1 repository(s): [...]
    

    This 422 (a StatusReasonInvalid, not a 409) was not retried, so the destroy failed even though the window clears moments later. The backend change and this client retry are complementary halves of the fix.

  2. Plan/refresh 5xx. A transient 500 (or 503/timeout/429) on a GET during plan/refresh failed the whole operation with no retry. No server-side change covers this yet, so it stands on its own.

Changes

  • Extract a generic retryWhile(ctx, attempts, delay, retryable, run) helper. retryOnConflict is now a thin wrapper over it — update-path behaviour is unchanged (still 409-only, 5×200ms). A non-positive attempt budget is clamped to a single attempt, so a misconfigured budget can never return nil without running.
  • Delete now retries on conflict (409) or a transient referential-integrity error (422 Invalid whose message reports it is still "referenced by" another resource), bounded at 6×2s so a genuinely-permanent reference still surfaces its real error promptly rather than hanging.
  • Read and ImportState now retry transient server errors (500/503/server-timeout/504/429) at 5×1s; NotFound is unaffected and still removes the resource from state.
  • Predicates (isReferencedDependencyError, isRetryableDeleteError, isRetryableServerError) and retryWhile are unit-tested, including negative cases (a genuine validation 422 is not retried) and the non-positive-attempts guard.

Scope / blast radius

The retry logic lives in the generic App Platform Resource[T, L] base, so it applies to all App Platform resources, but the new behaviour only changes outcomes for the transient error classes above; non-retryable errors fail exactly as before. The referential-integrity 422 is the documented behaviour of the provisioning connection delete validator (apps/provisioning/pkg/connection/delete_validator.go).

Related backend work

Notes / follow-ups

  • The "referenced by" classification matches the admission controller's message text (gated behind IsInvalid); the marker is centralized in a named constant with a source pointer. The 422 is a generic Invalid + Forbidden on metadata.name, so the message text is the only distinguishing signal available client-side — a stable machine-readable marker would be a backend follow-up.
  • The update-path's inner Get (on conflict re-fetch) is intentionally left as conflict-only retry — pre-existing behaviour, out of scope for this fix.
  • The combined connection+repository destroy path is exercised by the existing TestAccProvisioningRepository_viaConnection acceptance test.

Testing

  • go test ./internal/resources/appplatform/... — pass
  • go vet ./internal/resources/appplatform/... — clean
  • golangci-lint run ./internal/resources/appplatform/... — 0 issues
  • gofmt — clean

No schema/example changes, so no docs regeneration required.

@github-actions

Copy link
Copy Markdown
Contributor

In order to lower resource usage and have a faster runtime, PRs will not run Cloud tests automatically.
A Grafana Labs employee can trigger them by commenting /run-cloud-tests on this PR, or via the Actions UI for the fix/provisioning-delete-read-retries branch.

@rknightion

Copy link
Copy Markdown
Contributor Author

/run-cloud-tests

@github-actions

Copy link
Copy Markdown
Contributor

Cloud acceptance tests dispatched for fix/provisioning-delete-read-retries: view run

@rknightion
rknightion marked this pull request as ready for review June 17, 2026 10:35
@rknightion
rknightion requested a review from a team as a code owner June 17, 2026 10:35
@rknightion
rknightion requested review from ferruvich and konsalex and removed request for a team June 17, 2026 10:35
@rknightion
rknightion enabled auto-merge (squash) June 17, 2026 10:39
@rknightion
rknightion requested a review from Copilot June 17, 2026 10:41

Copilot AI 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.

⚠️ Not ready to approve

retryWhile can return nil without executing the operation when attempts is 0, which can mask real errors and should be guarded against.

Pull request overview

This PR hardens App Platform resource CRUD operations against eventual-consistency and transient backend failures by introducing a generic retry helper and expanding retry conditions for reads and deletes in the shared Resource[T, L] base.

Changes:

  • Adds a generic retryWhile helper and refactors retryOnConflict to wrap it.
  • Extends Delete retries to include transient referential-integrity 422 Invalid errors (and keeps retrying 409 conflicts) with a longer bounded budget.
  • Adds Read/ImportState retries for transient server-side errors (5xx/timeouts/429) to reduce flaky plan/refresh failures.
File summaries
File Description
internal/resources/appplatform/resource.go Introduces retryWhile + retry predicates; applies retry to Read/ImportState and broadens Delete retry behavior.
internal/resources/appplatform/resource_test.go Adds unit tests covering the new retry helper and retryability predicates (positive + negative cases).

Copilot's findings

  • Files reviewed: 2/2 changed files
  • Comments generated: 1

Note

Your feedback helps us improve the quality of this feature.
Please use 👍 or 👎 to tell us whether this assessment is correct.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread internal/resources/appplatform/resource.go
@MissingRoberto

Copy link
Copy Markdown
Contributor

I think this should be solved in Grafana directly, and not the provider. If you received a 200 for the deletion of the repository, connection should not fail.

@MissingRoberto

Copy link
Copy Markdown
Contributor

Backend context for this PR, now that the server-side change has landed.

The 422-on-delete retry here is a required half of the fix — please keep it.

On the backend, grafana/grafana#126822 changed the connection delete validator to ignore repositories that are themselves terminating (DeletionTimestamp != nil). That addresses the root cause of the terraform destroy failure:

422 Forbidden: cannot delete connection: referenced by N repository(s)

But it does not fully eliminate the 422 — it shrinks it to a storage read-after-write window. The validator decides at admission by doing a live ListByConnection (field selector spec.connection.name):

  1. repository DELETE200 (deletionTimestamp committed)
  2. ~100ms later, connection DELETE → validator lists repositories

If that list has not yet observed the repository's freshly-written deletionTimestamp (plausible — a field-selector list is likely served from the eventual-consistent search/index path rather than a quorum read), the validator still counts the repository as live and returns the same 422. Without a client retry, that transient 422 fails the run.

So the two changes are complementary and both are needed:

  • Backend (#126822): turns a persistent 422 (entire finalizer-completion window, seconds) into a transient one (storage-lag window).
  • This PR: retries that transient 422 so the module succeeds.

Separately, the read-retry for transient 5xx (the intermittent 500-on-GET during plan/refresh) in this PR addresses a distinct backend issue that no server-side change covers yet, so that part stands on its own.

For the record: a backend finalizer approach was explored (grafana/grafana#126819) but closed — it doesn't touch the validator's read path, so it would not have removed the need for this retry.

@MissingRoberto
MissingRoberto self-requested a review June 19, 2026 08:44

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

a couple minor comments about the delay values

Comment thread internal/resources/appplatform/resource.go Outdated
Comment thread internal/resources/appplatform/resource.go Outdated
Comment thread internal/resources/appplatform/resource.go

@ferruvich ferruvich left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Overall LGTM - I agree on @MissingRoberto's comment about durations we set for delays

@rknightion

Copy link
Copy Markdown
Contributor Author

Keeping the 422 retry. Updated the deleteRetry* / isReferencedDependencyError comments and the PR description to the post-#126822 model (residual 422 = storage read-after-write window on the validator's live ListByConnection, not a finalizer wait), and linked #126822 / #126819. 5xx read-retry stands on its own as you said.

@MissingRoberto

Copy link
Copy Markdown
Contributor

Tuned the retry counts to match the new delays (pushed in 387b46f): delete is now 4 × 2s (~6s wall) and read 3 × 1s (~2s wall), keeping the total wait budgets bounded so a genuinely-permanent reference still surfaces its real error promptly.

Follow-up: these flat attempts × delay budgets are a reasonable first cut, but they're still a guess about the reconcile / read-after-write window. We'll open a follow-up to move this to exponential backoff (catch the common sub-second clear fast, grow the delay to absorb a slow window, ideally bounded by elapsed wall-time rather than a fixed attempt count). Keeping it flat here to keep this fix focused.

@MissingRoberto
MissingRoberto requested a review from ferruvich June 19, 2026 09:21
@MissingRoberto

Copy link
Copy Markdown
Contributor

@rknightion you commits must be verified :(

rknightion and others added 3 commits June 19, 2026 10:54
…rors

App Platform resource deletes only retried HTTP 409 conflicts, and reads
did not retry at all. This caused two intermittent failures against the
provisioning (Git Sync) API:

- `terraform destroy` of a connection that a just-deleted repository still
  references fails with a 422 "cannot delete connection: referenced by N
  repository(s)". The repository DELETE returns 200 before the reference is
  reconciled away, so the connection DELETE briefly observes a stale
  reference. This transient referential-integrity error is now retried.
- `terraform plan`/refresh fails when a GET returns a transient 5xx. Reads
  now retry on 500/503/timeout/429.

Extracts a generic retryWhile helper (retryOnConflict is now a thin wrapper,
behaviour unchanged) and adds predicates for the retryable delete and read
error classes, with unit tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
…26822 backend

Address review feedback on the App Platform delete/read retries:

- deleteRetryDelay 1s -> 2s (6 attempts, ~10s total) per reviewer suggestion;
  conservative against the validator's field-selector list lagging past the
  ~100ms happy path.
- readRetryDelay 200ms -> 1s (5 attempts, ~4s total); 800ms was too tight for a
  transient 5xx to clear mid-reconcile, and reads only sleep on an actual error.
- Reframe the deleteRetry* and isReferencedDependencyError comments to the
  post-#126822 model: the residual 422 is a storage read-after-write window on
  the connection delete validator's live ListByConnection, not a wait for the
  repository's finalizers to complete.
- Add a TestRetryWhile case pinning the non-positive-attempts guard (runs once
  and surfaces the error, never returns nil).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
With delete delay at 2s and read delay at 1s, lower the attempt counts so
the total wait budgets stay bounded: delete 6->4 (~6s wall) and read 5->3
(~2s wall). Permanent references still surface their real error promptly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@rknightion
rknightion force-pushed the fix/provisioning-delete-read-retries branch from e7699fe to 79127b2 Compare June 19, 2026 09:54
@rknightion

Copy link
Copy Markdown
Contributor Author

@rknightion you commits must be verified :(

sorry (first time comiting to a grafana repo). Signed now and rebased (worth noting for #2844 so you can rebase and avoid reintroducing my unsigned commits)

@rknightion
rknightion merged commit 98856a7 into main Jun 19, 2026
48 of 50 checks passed
@rknightion
rknightion deleted the fix/provisioning-delete-read-retries branch June 19, 2026 10:02
MissingRoberto added a commit that referenced this pull request Jun 19, 2026
#2844)

## Summary

Follow-up to #2838 (now merged). That PR added retry coverage for
transient referential-integrity (422) and server (5xx/429) errors in the
App Platform resource base, but retried with a **fixed** delay between
attempts. This PR makes those retries use **exponential backoff with
jitter**, reusing **`k8s.io/apimachinery/pkg/util/wait`** rather than
hand-rolling the backoff — it's already a direct dependency and the
idiomatic primitive for this K8s-native file (which already uses
`apierrors`).

## Changes

- Each retry policy is now a `wait.Backoff` (initial `Duration` ×
`Factor` per step, additive `Jitter` to desynchronize parallel retries,
capped at `Cap`, bounded by `Steps`), shared across the conflict
(update), delete, and read/import paths:

| Policy | Duration | Factor | Cap | Steps | Base sequence | Worst case
(jittered) |
  |---|---|---|---|---|---|---|
| conflict (update) | 200ms | 2 | 2s | 5 | 200ms→400ms→800ms→1.6s |
~4.5s |
  | delete | 500ms | 2 | 2s | 4 | 500ms→1s→2s | ~5.25s |
  | read / import | 500ms | 2 | 2s | 4 | 500ms→1s→2s | ~5.25s |

Initial delays for **delete** and **read** are small (500ms): with
exponential backoff the first retry fires quickly to recover from the
common quick-clearing case (e.g. the validator's storage
read-after-write window, a momentary 5xx), and the curve grows to cover
a slower tail. The whole budget per policy stays **under 10s** so a
transient failure never stalls an operation for long.

- `retryWhile` / `retryOnConflict` now wrap
**`wait.ExponentialBackoffWithContext`**, which owns the context-aware
sleep between attempts. The wrappers preserve the behaviour #2838 relied
on:
- surface `fn`'s **last error** on `Steps` exhaustion (not wait's
sentinel `ErrWaitTimeout`),
  - surface the **context error** on cancellation,
- clamp a non-positive `Steps` to a single attempt so a misconfigured
budget still runs `fn` once (never a silent `nil`).
- Removes the hand-written `backoffDelay` / `waitForRetry` helpers and
the `math/rand` jitter.

## Why a library instead of hand-rolling

`wait.Backoff` expresses exactly what we'd otherwise write by hand
(base, factor, jitter, cap, max attempts), and
`wait.ExponentialBackoffWithContext` is the context-aware loop. The file
already imports `k8s.io/apimachinery/pkg/api/errors`, so this adds no
new dependency and keeps the retry logic idiomatic for the K8s API
surface these resources talk to.

## Scope / blast radius

Same generic App Platform `Resource[T, L]` base as #2838, so it applies
to all App Platform resources. Behaviour change is timing-only: the same
error classes are retried, just with growing, jittered waits bounded by
each policy's `Cap`/`Steps`. Non-retryable errors fail exactly as
before.

Note on delete coverage: #2838 used a conservative 2s initial delay
because the connection delete validator's field-selector list may be
served from an eventual-consistent index path; its only cited data point
was a ~100ms happy-path window, so 4 attempts spanning ~5.5s should
cover it. If a destroy ever surfaces a transient 422 in the wild, bump
delete's `Cap` to 4s and `Steps` to 5 (~9s jittered, still under 10s).

Note: #2838 deliberately left the conflict/update path on fixed delay;
this PR brings it under the same `wait.Backoff` scheme for consistency
(still conflict-only). Happy to revert just that call site if reviewers
prefer. The `appplatform/generic` subpackage has its own separate retry
helper and is out of scope.

## Testing

- `go test ./internal/resources/appplatform/...` — pass. Existing
`TestRetryWhile` / `TestRetryOnConflict` updated for the `wait.Backoff`
signature (using a zero-`Duration` backoff so they never sleep); new
`TestRetryBackoffPolicies` guards the production policies (`Steps>=1`,
`Factor>1`, `Jitter>0`, `Cap>=Duration`) **and asserts each policy's
worst-case total wait stays ≤ 10s** so a future retune can't silently
blow the budget.
- `go vet ./internal/resources/appplatform/...` — clean
- `gofmt` — clean
- `go build ./...` — clean
- `golangci-lint` not run locally (its Go 1.25 build is older than the
repo's 1.26.4 target); CI runs it in Docker.

No schema/example changes, so no docs regeneration required.

## Checklist
- [x] Tests added/updated
- [x] No docs needed (no schema/example changes)
- [x] No breaking changes (timing-only behaviour change)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Duologic added a commit to grafana/crossplane-provider-grafana that referenced this pull request Jul 3, 2026
## Resolution

The `make generate` failure was caused by `terraform-provider-grafana`
v4.39.x pulling in the **k8s.io v0.36** stack while crossplane-runtime
v2.1.x still pins **controller-runtime v0.22** (which does not implement
client-go v0.36's `HasSyncedChecker()` on
`cache.ResourceEventHandlerRegistration`).

As a temporary workaround, this PR pins the whole k8s.io stack back to
v0.35.3 via `replace` directives (they're a co-versioned set and can't
straddle v0.35/v0.36 individually). `make submodules && make generate`
now completes and the regenerated code is committed.

- Removal of the `replace` block is tracked in #622 (blocked on
controller-runtime v0.24 upstream: crossplane/crossplane-runtime#1037 +
crossplane/upjet#664).

Closes #601

---

## Automated dependency update

Updates `terraform-provider-grafana` to v4.39.1.

**Release**:
https://github.com/grafana/terraform-provider-grafana/releases/tag/v4.39.1

### Changelog

### Bug Fixes
- **appplatform:** retry transient referential-integrity and server
errors
([#2838](grafana/terraform-provider-grafana#2838))
by @rknightion
- **appplatform:** exponential backoff for retries via k8s wait.Backoff
([#2844](grafana/terraform-provider-grafana#2844))
by @MissingRoberto
- **asserts:** omit empty match values for null-check operators
([#2854](grafana/terraform-provider-grafana#2854))
by @vpadi
- **deps:** update module github.com/prometheus/common to v0.69.0
([#2827](grafana/terraform-provider-grafana#2827))
by @renovate-sh-app[bot]

### CI/CD
- **workflows:** add workflow for validation of unpublished builds
([#2780](grafana/terraform-provider-grafana#2780))
by @suntala
- **workflows:** align validate-unpublished-provider with field-eng
([#2845](grafana/terraform-provider-grafana#2845))
by @suntala
- **workflows:** poll cloud acceptance gate until tests finish
([#2855](grafana/terraform-provider-grafana#2855))
by @suntala

### Documentation
- clarify provider config for git sync App Platform resources
([#2843](grafana/terraform-provider-grafana#2843))
by @MissingRoberto

### Miscellaneous
- **cloud:** move to new stacks connections endpoint
([#2840](grafana/terraform-provider-grafana#2840))
by @nachogiljaldo

**Full Changelog**:
[v4.39.0...v4.39.1](grafana/terraform-provider-grafana@v4.39.0...v4.39.1)

---
*This PR was automatically created by the
[update-terraform-provider](https://github.com/grafana/crossplane-provider-grafana/actions/workflows/update-terraform-provider.yaml)
workflow.*

---------

Co-authored-by: terraform-provider-grafana[bot] <220933401+terraform-provider-grafana[bot]@users.noreply.github.com>
Co-authored-by: Duologic <jeroen@simplistic.be>
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.

4 participants