fix(appplatform): retry transient referential-integrity and server errors - #2838
Conversation
|
In order to lower resource usage and have a faster runtime, PRs will not run Cloud tests automatically. |
|
/run-cloud-tests |
|
Cloud acceptance tests dispatched for |
There was a problem hiding this comment.
⚠️ 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
retryWhilehelper and refactorsretryOnConflictto 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.
|
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. |
|
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 ( 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
If that list has not yet observed the repository's freshly-written So the two changes are complementary and both are needed:
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
left a comment
There was a problem hiding this comment.
a couple minor comments about the delay values
ferruvich
left a comment
There was a problem hiding this comment.
Overall LGTM - I agree on @MissingRoberto's comment about durations we set for delays
|
Keeping the 422 retry. Updated the |
|
Tuned the retry counts to match the new delays (pushed in 387b46f): delete is now Follow-up: these flat |
|
@rknightion you commits must be verified :( |
…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>
e7699fe to
79127b2
Compare
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) |
#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>
## 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>
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:
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 repositoryDELETEreturns200and stamps adeletionTimestamp, but the object lingers in storage until its finalizers complete.The connection delete validator decides at admission via a live
ListByConnection(field selectorspec.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-writtendeletionTimestamp(~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: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.Plan/refresh 5xx. A transient
500(or503/timeout/429) on aGETduring plan/refresh failed the whole operation with no retry. No server-side change covers this yet, so it stands on its own.Changes
retryWhile(ctx, attempts, delay, retryable, run)helper.retryOnConflictis 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 returnnilwithout running.Invalidwhose 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.NotFoundis unaffected and still removes the resource from state.isReferencedDependencyError,isRetryableDeleteError,isRetryableServerError) andretryWhileare 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
IsInvalid); the marker is centralized in a named constant with a source pointer. The 422 is a genericInvalid+Forbiddenonmetadata.name, so the message text is the only distinguishing signal available client-side — a stable machine-readable marker would be a backend follow-up.Get(on conflict re-fetch) is intentionally left as conflict-only retry — pre-existing behaviour, out of scope for this fix.TestAccProvisioningRepository_viaConnectionacceptance test.Testing
go test ./internal/resources/appplatform/...— passgo vet ./internal/resources/appplatform/...— cleangolangci-lint run ./internal/resources/appplatform/...— 0 issuesgofmt— cleanNo schema/example changes, so no docs regeneration required.