ETag Support in Book Store API
Overview
The Book Store API implements ETags (Entity Tags) for:
- Optimistic Concurrency Control - Prevent conflicting updates
- HTTP Caching - Reduce bandwidth and improve performance
ETags are generated from Marten's event stream versions, ensuring they accurately reflect the current state of resources.
How ETags Work
ETag Generation
ETag = "stream_version"
Example: "5" (indicates this is version 5 of the resource)
Every time a resource is modified (updated, deleted, restored), the stream version increments, and the ETag changes.
Read Operations (GET)
Get Book by ID
Request:
GET /api/books/{id}
Response (First Request):
HTTP/1.1 200 OK
ETag: "3"
Content-Type: application/json
{
"id": "book-123",
"title": "Clean Code",
...
}
Conditional Request (Subsequent):
GET /api/books/{id}
If-None-Match: "3"
Response (Not Modified):
HTTP/1.1 304 Not Modified
ETag: "3"
Response (Modified):
HTTP/1.1 200 OK
ETag: "4"
Content-Type: application/json
{
"id": "book-123",
"title": "Clean Code (Updated)",
...
}
Benefits
- ✅ Bandwidth savings: No body sent with 304 responses
- ✅ Reduced server load: Cached responses when content unchanged
- ✅ Automatic: Browsers and HTTP clients handle this automatically
Write Operations (PUT/DELETE)
Update Book
Step 1: Get Current Version
GET /api/books/{id}
Response includes ETag: "3"
Step 2: Update with If-Match
PUT /api/admin/books/{id}
If-Match: "3"
Content-Type: application/json
{
"title": "Clean Code (Updated)",
...
}
Success Response:
HTTP/1.1 204 No Content
ETag: "4"
Conflict Response (Someone else updated it):
HTTP/1.1 412 Precondition Failed
Content-Type: application/problem+json
{
"title": "Precondition Failed",
"detail": "The resource has been modified since you last retrieved it. Please refresh and try again.",
"status": 412
}
Soft Delete Book
DELETE /api/admin/books/{id}
If-Match: "4"
Success:
HTTP/1.1 204 No Content
ETag: "5"
Restore Book
POST /api/admin/books/{id}/restore
If-Match: "5"
Success:
HTTP/1.1 204 No Content
ETag: "6"
Client Implementation Examples
JavaScript/TypeScript
class BookApiClient {
private baseUrl = 'http://localhost:5000/api';
async getBook(id: string): Promise<{ book: Book; etag: string }> {
const response = await fetch(`${this.baseUrl}/books/${id}`);
const etag = response.headers.get('ETag') || '';
const book = await response.json();
return { book, etag };
}
async updateBook(id: string, book: UpdateBookRequest, etag: string): Promise<void> {
const response = await fetch(`${this.baseUrl}/admin/books/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'If-Match': etag
},
body: JSON.stringify(book)
});
if (response.status === 412) {
throw new Error('Book was modified by another user. Please refresh and try again.');
}
if (!response.ok) {
throw new Error(`Update failed: ${response.statusText}`);
}
}
async getBookWithCache(id: string, cachedETag?: string): Promise<Book | null> {
const headers: HeadersInit = {};
if (cachedETag) {
headers['If-None-Match'] = cachedETag;
}
const response = await fetch(`${this.baseUrl}/books/${id}`, { headers });
if (response.status === 304) {
return null; // Use cached version
}
return await response.json();
}
}
C# HttpClient
public class BookApiClient
{
private readonly HttpClient _httpClient;
public async Task<(Book Book, string ETag)> GetBookAsync(Guid id)
{
var response = await _httpClient.GetAsync($"/api/books/{id}");
response.EnsureSuccessStatusCode();
var etag = response.Headers.ETag?.Tag ?? "";
var book = await response.Content.ReadFromJsonAsync<Book>();
return (book!, etag);
}
public async Task UpdateBookAsync(Guid id, UpdateBookRequest request, string etag)
{
var requestMessage = new HttpRequestMessage(HttpMethod.Put, $"/api/admin/books/{id}")
{
Content = JsonContent.Create(request)
};
requestMessage.Headers.IfMatch.Add(new EntityTagHeaderValue(etag));
var response = await _httpClient.SendAsync(requestMessage);
if (response.StatusCode == HttpStatusCode.PreconditionFailed)
{
throw new InvalidOperationException(
"Book was modified by another user. Please refresh and try again.");
}
response.EnsureSuccessStatusCode();
}
}
Python Requests
import requests
class BookApiClient:
def __init__(self, base_url='http://localhost:5000/api'):
self.base_url = base_url
def get_book(self, book_id):
response = requests.get(f'{self.base_url}/books/{book_id}')
response.raise_for_status()
return {
'book': response.json(),
'etag': response.headers.get('ETag', '')
}
def update_book(self, book_id, book_data, etag):
response = requests.put(
f'{self.base_url}/admin/books/{book_id}',
json=book_data,
headers={'If-Match': etag}
)
if response.status_code == 412:
raise ValueError('Book was modified by another user. Please refresh.')
response.raise_for_status()
return response.headers.get('ETag', '')
Workflow Examples
Example 1: Safe Update Workflow
# 1. Get current book
GET /api/books/123
# Response: ETag: "5"
# 2. User edits book in UI
# 3. Submit update with ETag
PUT /api/admin/books/123
If-Match: "5"
{
"title": "Updated Title",
...
}
# Success: ETag: "6"
Example 2: Concurrent Update Detection
# User A gets book
GET /api/books/123
# Response: ETag: "5"
# User B gets book
GET /api/books/123
# Response: ETag: "5"
# User B updates first
PUT /api/admin/books/123
If-Match: "5"
# Success: ETag: "6"
# User A tries to update
PUT /api/admin/books/123
If-Match: "5"
# Error: 412 Precondition Failed (ETag mismatch)
# User A refreshes and gets new version
GET /api/books/123
# Response: ETag: "6"
# User A updates with new ETag
PUT /api/admin/books/123
If-Match: "6"
# Success: ETag: "7"
Example 3: Efficient Caching
# First request
GET /api/books/123
# Response: 200 OK, ETag: "5", Full body
# Subsequent request (within cache period)
GET /api/books/123
If-None-Match: "5"
# Response: 304 Not Modified (no body, saves bandwidth)
# After someone updates the book
GET /api/books/123
If-None-Match: "5"
# Response: 200 OK, ETag: "6", Full body (content changed)
Error Handling
412 Precondition Failed
Cause: The If-Match ETag doesn't match the current resource version
Client Action:
- Notify user that the resource was modified
- Fetch the latest version
- Ask user to review changes and resubmit
Example:
try {
await updateBook(id, data, etag);
} catch (error) {
if (error.status === 412) {
// Fetch latest version
const { book, etag: newETag } = await getBook(id);
// Show user the conflict
showConflictDialog({
yourChanges: data,
currentVersion: book,
onResolve: (resolved) => updateBook(id, resolved, newETag)
});
}
}
Best Practices
For Clients
- Always store ETags when fetching resources
- Always send If-Match for PUT/DELETE operations
- Handle 412 gracefully - don't just retry
- Use If-None-Match for GET requests to leverage caching
- Don't ignore ETags - they prevent data loss
For UI Applications
// Good: Store ETag with resource
interface BookState {
book: Book;
etag: string;
lastFetched: Date;
}
// Good: Validate before update
async function saveBook(state: BookState, changes: Partial<Book>) {
try {
await api.updateBook(state.book.id, changes, state.etag);
} catch (error) {
if (error.status === 412) {
// Refresh and ask user to review
const latest = await api.getBook(state.book.id);
showConflictResolution(state.book, latest.book, changes);
}
}
}
For Batch Operations
// Process updates sequentially to handle conflicts
async function batchUpdate(books: Array<{id: string, data: any, etag: string}>) {
const results = [];
for (const book of books) {
try {
await api.updateBook(book.id, book.data, book.etag);
results.push({ id: book.id, success: true });
} catch (error) {
if (error.status === 412) {
results.push({ id: book.id, success: false, reason: 'conflict' });
} else {
results.push({ id: book.id, success: false, reason: 'error' });
}
}
}
return results;
}
Testing ETags
Manual Testing with curl
# Get book and extract ETag
curl -i http://localhost:5000/api/books/123
# Note the ETag header
# Update with correct ETag (should succeed)
curl -X PUT http://localhost:5000/api/admin/books/123 \
-H "If-Match: \"5\"" \
-H "Content-Type: application/json" \
-d '{"title": "Updated"}'
# Update with wrong ETag (should fail with 412)
curl -X PUT http://localhost:5000/api/admin/books/123 \
-H "If-Match: \"999\"" \
-H "Content-Type: application/json" \
-d '{"title": "Updated"}'
# Test caching with If-None-Match
curl -i http://localhost:5000/api/books/123 \
-H "If-None-Match: \"5\""
# Should return 304 if not modified
Summary
- Read Operations: Use
If-None-Matchfor efficient caching (304 responses) - Write Operations: Use
If-Matchfor optimistic concurrency (prevent conflicts) - ETags: Generated from Marten stream versions (auto-incremented)
- Error Handling: 412 Precondition Failed when version mismatch
- Client Responsibility: Store ETags, handle conflicts gracefully