Skip to content

fix(objectstorage): retry enabling the project on 409 conflict - #1645

Open
FabianHardt wants to merge 3 commits into
stackitcloud:mainfrom
FabianHardt:objectstorage-retry-enable-conflict
Open

fix(objectstorage): retry enabling the project on 409 conflict#1645
FabianHardt wants to merge 3 commits into
stackitcloud:mainfrom
FabianHardt:objectstorage-retry-enable-conflict

Conversation

@FabianHardt

Copy link
Copy Markdown

Problem

stackit_objectstorage_bucket, stackit_objectstorage_credential and
stackit_objectstorage_credentials_group each enable object storage for the
project before creating their own object. When two of them are created in the
same apply and nothing forces an order, Terraform runs them in parallel and the
API rejects the losing call:

Error: Enabling object storage project before creation: failed to create object
storage project: 409 Conflict  ([{project.create_conflict Two concurrent calls
try to create the same project}]), status code 409

The apply fails although nothing is actually wrong — the competing call enables
the project a moment later.

Minimal reproducer (both resources in one config, no reference between them):

resource "stackit_objectstorage_bucket" "b" {
  project_id = var.project_id
  name       = "some-bucket"
}

resource "stackit_objectstorage_credentials_group" "g" {
  project_id = var.project_id
  name       = "some-group"
}

The comment in enableProject already assumes idempotency — "Creation will
also be successful if the project is already enabled"
— which holds for
sequential calls but not for concurrent ones.

Change

enableProject retries on 409 and leaves every other error untouched. An
apply no longer depends on the order in which Terraform happens to start the
resources.

depends_on works around it today, but that requires knowing about an implicit
API call the resource documentation does not mention — the error message points
at object storage projects, not at a missing dependency.

The retry is deliberately narrow rather than utils.RetryRequest: that helper
also retries errors which are not GenericOpenAPIError, which would slow the
existing unit tests down.

Tests

Added to credentialsgroup/resource_test.go:

  • TestEnableProjectRetriesOnConflict — succeeds immediately, one conflict then
    success, and conflicts until the attempts are used up; asserts the number of
    API calls in each case
  • TestEnableProjectDoesNotRetryOtherErrors — a 403 must fail on the first
    attempt

The retry delay is a package variable so tests can shorten it.

go test ./stackit/internal/services/objectstorage/...   -> ok (all packages)
gofmt -l / go vet                                       -> clean

Note

The three copies of enableProject are identical; I kept the duplication to
keep the diff reviewable. Happy to extract it into
objectstorage/utils instead if you prefer.

bucket, credential and credentials group each enable object storage for the
project before creating their own object. When two of them are created in the
same apply, Terraform runs them in parallel and the API rejects the losing
call:

  Error: Enabling object storage project before creation: failed to create
  object storage project: 409 Conflict
  ([{project.create_conflict Two concurrent calls try to create the same
  project}]), status code 409

The apply fails, although nothing is wrong - the competing call enables the
project a moment later. The comment in enableProject already assumed the call
to be idempotent ("Creation will also be successful if the project is already
enabled"), which holds for sequential calls but not for concurrent ones.

enableProject now retries on 409 and leaves every other error untouched, so an
apply no longer depends on the order in which Terraform happens to start the
resources. Users can work around it today with depends_on, but that requires
knowing about an implicit call that the resource documentation does not
mention.

The retry is deliberately narrow rather than utils.RetryRequest: that helper
also retries errors that are not API errors, which would slow down the
existing unit tests.

Signed-off-by: Fabian Hardt <fabian.hardt@opitz-consulting.com>
@FabianHardt
FabianHardt requested a review from a team as a code owner August 2, 2026 09:43
Comment on lines +415 to +433
var err error
for attempt := 0; attempt < enableProjectAttempts; attempt++ {
_, err = client.EnableService(ctx, projectId, region).Execute()
if err == nil {
return nil
}

var oapiErr *oapierror.GenericOpenAPIError
if !errors.As(err, &oapiErr) || oapiErr.StatusCode != http.StatusConflict {
break
}

timer := time.NewTimer(enableProjectRetryDelay)
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}

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 a full review, but we recently added some retry utils that looks like it might be applicable here. See retry.go

Per review: utils.RetryRequest already covers this, and the loop was duplicated
across all three resources.

One behavioural difference worth naming: RetryRequest only filters by status
code when the error can be type-asserted to *oapierror.GenericOpenAPIError.
Anything else - a network failure, a transport error - is now retried as well,
where the previous loop bailed out immediately. For an idempotent enable call
that seems reasonable, but it is a change, not a refactor.

It also shows up in the existing TestEnableProject: its mock returns a plain
error, so the failing case now uses every attempt. Those tests shrink the
retry delay so they stay fast.
@FabianHardt

Copy link
Copy Markdown
Author

Good call, thanks — swapped in utils.RetryRequest. It drops the hand-rolled loop from all three resources (net -11 lines) and keeps the behaviour I was after: 4 attempts, 2s apart, retrying only on 409.

One difference I want to flag rather than bury, because it's a change and not a refactor: RetryRequest only applies RetryStatusCodes when the error type-asserts to *oapierror.GenericOpenAPIError. Anything else — a transport or network failure — now gets retried too, where my loop bailed out immediately. For an idempotent enable call that seems fine to me, but it's your call whether that's the semantics you want.

It surfaced in the existing TestEnableProject: its mock returns a plain error, so the failing case now uses every attempt and the test slept for 6s in each of the three packages. I shrank the retry delay in those tests — they're back under 2s.

go build, go vet and go test ./stackit/internal/services/objectstorage/... all pass.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants