409 Conflict — Optimistic Locking Failure
Belirtiler
- PUT or PATCH requests intermittently return 409 Conflict, especially when multiple users or concurrent processes edit the same resource
- The response body may include `'conflict': 'Resource was modified since you last fetched it'` or an ETag mismatch message
- Single-user testing in a clean environment never reproduces the issue — it only appears under concurrent load or after a background job runs
- The ETag value returned from a GET differs from what the client sends in the `If-Match` header on the subsequent PUT, triggering the conflict check
- Race conditions in distributed systems or microservices produce 409 when two services write to the same record within milliseconds of each other
- The response body may include `'conflict': 'Resource was modified since you last fetched it'` or an ETag mismatch message
- Single-user testing in a clean environment never reproduces the issue — it only appears under concurrent load or after a background job runs
- The ETag value returned from a GET differs from what the client sends in the `If-Match` header on the subsequent PUT, triggering the conflict check
- Race conditions in distributed systems or microservices produce 409 when two services write to the same record within milliseconds of each other
Kök Nedenler
- Two users editing the same resource simultaneously — the second writer's PUT overwrites the first writer's changes without warning unless the API implements optimistic locking
- ETag or `If-Match` header mismatch — the client fetched the resource, the server updated it (e.g., via background job), and the client's cached ETag no longer matches the server's current version
- Database row version number or updated_at timestamp conflict in optimistic concurrency control — server rejects writes when the client's version is stale
- Background job (e.g., a scheduled task or async worker) modifying the resource between the client's GET request and its subsequent PUT
- Race condition in a distributed system where eventual consistency allows two nodes to accept conflicting writes that must be reconciled
Tanı
**Step 1 — Simulate a concurrent conflict to confirm ETag-based locking**
```bash
# Terminal 1: Fetch resource and note the ETag
curl -v https://api.example.com/items/42
# < ETag: "abc123"
# Terminal 2: Update the resource (this changes the ETag)
curl -X PUT https://api.example.com/items/42 \
-H 'If-Match: "abc123"' \
-H 'Content-Type: application/json' \
-d '{"name": "updated by user 2"}'
# < HTTP/1.1 200 OK
# < ETag: "def456" ← ETag has changed!
# Terminal 1: Try to update with the stale ETag
curl -v -X PUT https://api.example.com/items/42 \
-H 'If-Match: "abc123"' \
-H 'Content-Type: application/json' \
-d '{"name": "updated by user 1"}'
# < HTTP/1.1 409 Conflict
```
**Step 2 — Check if the API uses version numbers instead of ETags**
```bash
# Some APIs embed a version in the resource body:
curl https://api.example.com/items/42
# {"id": 42, "name": "Item", "_version": 7}
# And require it on updates:
curl -X PUT https://api.example.com/items/42 \
-d '{"name": "Updated", "_version": 7}'
# Stale version → 409 Conflict
```
**Step 3 — Check the database-level lock/version implementation**
```python
# Django example — check if the model uses versioning:
from django.db import models
class Item(models.Model):
name = models.CharField(max_length=200)
version = models.IntegerField(default=1)
updated_at = models.DateTimeField(auto_now=True)
# Check if the view validates version on write:
def update_item(pk, data, client_version):
item = Item.objects.get(pk=pk)
if item.version != client_version:
raise ConflictError('Item was modified')
```
**Step 4 — Monitor for patterns in production logs**
```bash
# Count 409 responses by endpoint and time window:
grep '409' access.log \
| awk '{print $7}' \
| sort | uniq -c | sort -rn | head -20
# High 409 rate on one endpoint → concurrency problem there
```
```bash
# Terminal 1: Fetch resource and note the ETag
curl -v https://api.example.com/items/42
# < ETag: "abc123"
# Terminal 2: Update the resource (this changes the ETag)
curl -X PUT https://api.example.com/items/42 \
-H 'If-Match: "abc123"' \
-H 'Content-Type: application/json' \
-d '{"name": "updated by user 2"}'
# < HTTP/1.1 200 OK
# < ETag: "def456" ← ETag has changed!
# Terminal 1: Try to update with the stale ETag
curl -v -X PUT https://api.example.com/items/42 \
-H 'If-Match: "abc123"' \
-H 'Content-Type: application/json' \
-d '{"name": "updated by user 1"}'
# < HTTP/1.1 409 Conflict
```
**Step 2 — Check if the API uses version numbers instead of ETags**
```bash
# Some APIs embed a version in the resource body:
curl https://api.example.com/items/42
# {"id": 42, "name": "Item", "_version": 7}
# And require it on updates:
curl -X PUT https://api.example.com/items/42 \
-d '{"name": "Updated", "_version": 7}'
# Stale version → 409 Conflict
```
**Step 3 — Check the database-level lock/version implementation**
```python
# Django example — check if the model uses versioning:
from django.db import models
class Item(models.Model):
name = models.CharField(max_length=200)
version = models.IntegerField(default=1)
updated_at = models.DateTimeField(auto_now=True)
# Check if the view validates version on write:
def update_item(pk, data, client_version):
item = Item.objects.get(pk=pk)
if item.version != client_version:
raise ConflictError('Item was modified')
```
**Step 4 — Monitor for patterns in production logs**
```bash
# Count 409 responses by endpoint and time window:
grep '409' access.log \
| awk '{print $7}' \
| sort | uniq -c | sort -rn | head -20
# High 409 rate on one endpoint → concurrency problem there
```
Çözüm
**Resolution 1: Fetch-before-update pattern (client sends current ETag)**
```python
import httpx
def update_item(item_id: int, new_data: dict, max_retries: int = 3) -> dict:
client = httpx.Client(base_url='https://api.example.com')
for attempt in range(max_retries):
# Step 1: Fetch current resource and its ETag
resp = client.get(f'/items/{item_id}')
resp.raise_for_status()
etag = resp.headers.get('ETag')
# Step 2: Merge changes with current state
merged = {**resp.json(), **new_data}
# Step 3: Attempt update with If-Match
update = client.put(
f'/items/{item_id}',
json=merged,
headers={'If-Match': etag} if etag else {},
)
if update.status_code == 409:
if attempt < max_retries - 1:
continue # Retry with fresh data
raise RuntimeError('Persistent conflict — manual resolution needed')
update.raise_for_status()
return update.json()
raise RuntimeError('Max retries exceeded')
```
**Resolution 2: Server-side optimistic locking in Django**
```python
from django.db import models, transaction
from rest_framework.exceptions import Conflict
class Item(models.Model):
name = models.CharField(max_length=200)
version = models.PositiveIntegerField(default=1)
class ItemUpdateView(APIView):
def put(self, request, pk):
client_version = request.data.get('version')
with transaction.atomic():
updated = Item.objects.filter(
pk=pk,
version=client_version, # Only update if version matches
).update(
name=request.data['name'],
version=models.F('version') + 1,
)
if updated == 0:
raise Conflict('Resource was modified by another request')
return Response({'version': client_version + 1})
```
**Resolution 3: Last-write-wins (appropriate for non-critical fields)**
```python
# For non-critical updates where overwrites are acceptable:
def update_preference(user_id: int, data: dict) -> dict:
# Skip optimistic locking — last writer wins
UserPreference.objects.update_or_create(
user_id=user_id,
defaults=data,
)
```
```python
import httpx
def update_item(item_id: int, new_data: dict, max_retries: int = 3) -> dict:
client = httpx.Client(base_url='https://api.example.com')
for attempt in range(max_retries):
# Step 1: Fetch current resource and its ETag
resp = client.get(f'/items/{item_id}')
resp.raise_for_status()
etag = resp.headers.get('ETag')
# Step 2: Merge changes with current state
merged = {**resp.json(), **new_data}
# Step 3: Attempt update with If-Match
update = client.put(
f'/items/{item_id}',
json=merged,
headers={'If-Match': etag} if etag else {},
)
if update.status_code == 409:
if attempt < max_retries - 1:
continue # Retry with fresh data
raise RuntimeError('Persistent conflict — manual resolution needed')
update.raise_for_status()
return update.json()
raise RuntimeError('Max retries exceeded')
```
**Resolution 2: Server-side optimistic locking in Django**
```python
from django.db import models, transaction
from rest_framework.exceptions import Conflict
class Item(models.Model):
name = models.CharField(max_length=200)
version = models.PositiveIntegerField(default=1)
class ItemUpdateView(APIView):
def put(self, request, pk):
client_version = request.data.get('version')
with transaction.atomic():
updated = Item.objects.filter(
pk=pk,
version=client_version, # Only update if version matches
).update(
name=request.data['name'],
version=models.F('version') + 1,
)
if updated == 0:
raise Conflict('Resource was modified by another request')
return Response({'version': client_version + 1})
```
**Resolution 3: Last-write-wins (appropriate for non-critical fields)**
```python
# For non-critical updates where overwrites are acceptable:
def update_preference(user_id: int, data: dict) -> dict:
# Skip optimistic locking — last writer wins
UserPreference.objects.update_or_create(
user_id=user_id,
defaults=data,
)
```
Önleme
- **Design for idempotency** — PUT requests should be designed so that applying the same update twice produces the same result, using an `idempotency-key` header or embedding version numbers in the request
- **Return 409 with actionable details** including the current resource state so clients can render a diff UI or auto-merge non-conflicting fields
- **Use event sourcing for high-concurrency resources** — append-only event logs with optimistic concurrency control on the aggregate version eliminate many conflict scenarios by design
- **Load test concurrent writes in staging** — use `ab` or `locust` to send simultaneous PUT requests and verify your conflict detection fires correctly before shipping optimistic locking to production
- **Return 409 with actionable details** including the current resource state so clients can render a diff UI or auto-merge non-conflicting fields
- **Use event sourcing for high-concurrency resources** — append-only event logs with optimistic concurrency control on the aggregate version eliminate many conflict scenarios by design
- **Load test concurrent writes in staging** — use `ab` or `locust` to send simultaneous PUT requests and verify your conflict detection fires correctly before shipping optimistic locking to production