Fork API returns 409 error despite successful project creation (race condition)
Everyone can contribute. Help move this issue forward while earning points, leveling up and collecting rewards.
Summary
The POST /api/v4/projects/:id/fork endpoint sometimes returns a 409 Conflict error with the message {message: [Project namespace name has already been taken, Name has already been taken, Path has already been taken]}, even though the fork was successfully created at the exact time the API returned the error.
This is a race condition in the project creation transaction that causes false-negative responses, making the API unreliable for automated workflows.
Steps to reproduce
-
Use the fork API endpoint:
POST /api/v4/projects/:id/fork -
Observe that occasionally (intermittently) the API returns:
409 Conflict {message: [Project namespace name has already been taken, Name has already been taken, Path has already been taken]} -
Check the target namespace - the forked project exists and was created at the exact timestamp of the API call
Current behavior
- API returns 409 Conflict error
- Error message claims the path/name is already taken
- However, the project was successfully created in the database
- This happens intermittently, suggesting a race condition
Expected behavior
- If the fork is created successfully, return 201 Created with the project details
- If the fork fails due to a duplicate path, return 409 Conflict and do not create the project
- The API response should accurately reflect the actual outcome
Environment
- GitLab instance: Self-hosted (gitlab.example.com)
- API endpoint:
POST https://gitlab.example.com/api/v4/projects/244795/fork - Frequency: Intermittent (race condition)
Root Cause Analysis
Based on investigation of the GitLab codebase, this is a time-of-check to time-of-use (TOCTOU) race condition in the project creation transaction.
The Critical Code Path
In app/services/projects/create_service.rb:251-276:
ApplicationRecord.transaction do
@project.build_or_assign_import_data(data: @import_data[:data], credentials: @import_data[:credentials]) if @import_data
# Avoid project callbacks being triggered multiple times by saving the parent first.
# See https://github.com/rails/rails/issues/41701.
Namespaces::ProjectNamespace.create_from_project!(@project) if @project.valid? # Line 260
if @project.saved?
Integration.create_from_default_integrations(@project, :project_id)
# ...
end
end
In app/models/namespaces/project_namespace.rb:42-50:
def self.create_from_project!(project)
return unless project.new_record?
return unless project.namespace
proj_namespace = project.project_namespace || project.build_project_namespace
project.project_namespace.sync_attributes_from_project(project)
proj_namespace.save! # Validation runs AGAIN here - Line 48
proj_namespace
end
In app/models/route.rb:15:
validates :path, uniqueness: { case_sensitive: false }
What's Happening
-
First validation (
@project.valid?at line 260): Checks uniqueness of name/path - passes because no duplicate exists yet - Race window: Another concurrent fork request can slip in and create the same path
-
Second validation (inside
proj_namespace.save!at line 48): TheRoutemodel validates path uniqueness globally - fails because the concurrent request created the route -
Database trigger side effect: Due to the database trigger that synchronizes
projectsandnamespacestables (seeapp/models/project.rb:199-200), the project record may already be partially persisted:
# Sync deletion via DB Trigger to ensure we do not have
# a project without a project_namespace (or vice-versa)
belongs_to :project_namespace, autosave: true, class_name: 'Namespaces::ProjectNamespace', foreign_key: 'project_namespace_id', inverse_of: :project
Why This Creates the Observed Behavior
- The project gets created successfully by the first/winning request's database trigger
- The second/losing request hits the duplicate validation during
proj_namespace.save! - The validation errors propagate back to the API response as a 409 error
- But the project already exists in the database from the earlier transaction
This is a classic ActiveRecord race condition: the uniqueness validation performs a separate SELECT query before the INSERT, leaving a window for concurrent requests to both pass validation before either commits.
Proposed Solutions
-
Database-level locking: Use
SELECT FOR UPDATEon the parent namespace before creating the fork - Database constraints only: Rely solely on database constraints instead of ActiveRecord validations for uniqueness, and handle constraint violations gracefully
- Advisory locks: Use PostgreSQL advisory locks to serialize fork creation for the same namespace/path combination
- Optimistic locking with retry: Implement retry logic that detects and handles the race condition gracefully
- Idempotency check: Before returning 409, verify whether the project actually exists and was created by the current request
Impact
- API reliability: Clients cannot trust the HTTP status code
- Automation failures: CI/CD pipelines and automation tools fail unnecessarily
- User confusion: Users see errors but the operation succeeded
- Retry storms: Clients may retry the operation, potentially creating duplicate projects or wasting resources
Related Issues
- #214127 (closed) - Similar symptoms but was user error (incorrect API parameters)
- #535568 (closed) - Similar 409 errors with Terraform provider, closed as support request
Perfect! This is ready to submit to the GitLab issue tracker.