Skip to content

Commit e0397f0

Browse files
tvaron3Copilot
andauthored
[Cosmos] Add additional response headers (#3960)
## Summary Adds type-safe response types for Cosmos DB operations with access to additional response headers for diagnostics and metrics. Fixes #3905. ## Design Uses dedicated wrapper types for each operation category. A design spec is at `sdk/cosmos/azure_data_cosmos/docs/RESPONSE_METADATA_SPEC.md`. ### Response Types | Type | Operations | Extra Fields | |------|-----------|-------------| | `ItemResponse<T>` | create/read/replace/upsert/delete item | `etag() -> Option<&Etag>` | | `ResourceResponse<T>` | create/read/delete database/container, throughput | (future-proof placeholder) | | `BatchResponse` | execute_transactional_batch | `etag() -> Option<&Etag>` | | `QueryFeedPage<T>` | query_items, query_containers, query_databases | `index_metrics()`, `query_metrics()` | | `FeedPage<T>` | generic feed page (reusable for future read-many, change feed) | common fields only | | `CosmosDiagnostics` | all operations (via `diagnostics()`) | `activity_id()`, `server_duration_ms()` | ### Principle Each operation category gets a dedicated wrapper type. Common accessors (`request_charge()`, `session_token()`, `diagnostics()`) are on all types. Operation-specific fields are only on the relevant type. `QueryFeedPage<T>` composes over `FeedPage<T>`, adding query-specific metadata. `CosmosResponse<T>` is internal (`pub(crate)`). ## Driver Changes (`azure_data_cosmos_driver`) - Added `index_metrics`, `query_metrics`, `server_duration_ms`, and `lsn` fields to `CosmosResponseHeaders`. - `index_metrics` is base64-decoded (matching Java/.NET) with `tracing::warn!` on decode failure. - `server_duration_ms` filters non-finite and negative values. - Added `server_duration_ms` to `RequestDiagnostics`, populated from response headers in transport pipeline. - Made `from_headers()` public for cross-crate access. ## SDK Changes (`azure_data_cosmos`) - `ItemResponse<T>` wraps `CosmosResponse<T>` with `etag()` using `azure_core::http::Etag`. - `ResourceResponse<T>` wraps `CosmosResponse<T>` for resource management operations. - `BatchResponse` wraps `CosmosResponse<TransactionalBatchResponse>` with batch-level `etag()`. - `QueryFeedPage<T>` composes over `FeedPage<T>`, adding `index_metrics()` and `query_metrics()`. - `FeedPage<T>` is a public generic type with common feed fields, reusable for future read-many and change feed. - `CosmosDiagnostics` provides `activity_id()` and `server_duration_ms()` on all response types. - SDK delegates all header parsing to the driver's `CosmosResponseHeaders`. ## Tests - Driver unit tests: base64 decode, invalid base64, NaN/inf/negative filtering, JSON serialization. - SDK unit tests: wrapper types, diagnostics accessors, etag with `Etag` type, edge cases. - Integration tests: `assert_response` helper validates `diagnostics().activity_id()` and `diagnostics().server_duration_ms()` on all point operations; `query_returns_index_and_query_metrics` validates query-specific metadata with opt-in headers. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 212efbd commit e0397f0

28 files changed

Lines changed: 1057 additions & 270 deletions

Cargo.lock

Lines changed: 8 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

sdk/cosmos/.cspell.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,8 @@
115115
"pksysdocs",
116116
"pluggable",
117117
"pointee",
118+
"populateindexmetrics",
119+
"populatequerymetrics",
118120
"PPAF",
119121
"PPCB",
120122
"purgeable",

sdk/cosmos/azure_data_cosmos/CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88

99
### Breaking Changes
1010

11+
- Client methods now return dedicated response types instead of `CosmosResponse<T>`: `ItemResponse<T>` for point operations, `ResourceResponse<T>` for resource management, `BatchResponse` for transactional batch, and `QueryFeedPage<T>` for query pages. `etag()` returns `Option<&Etag>` instead of `Option<&str>`, and `activity_id()` / `server_duration_ms()` are accessed via `response.diagnostics()`. ([#3960](https://github.com/Azure/azure-sdk-for-rust/pull/3960))
12+
- `FeedPage::deconstruct()` has been removed. Use `into_items()`, `continuation()`, `headers()`, and `diagnostics()` instead. ([#3960](https://github.com/Azure/azure-sdk-for-rust/pull/3960))
1113
- Replaced `CosmosClientBuilder::with_application_region()` with a mandatory `RoutingStrategy` parameter on `build()`. Use `RoutingStrategy::ProximityTo(region)` to specify the application region. Also removed `CosmosClientOptions::with_application_region()`. ([#3889](https://github.com/Azure/azure-sdk-for-rust/pull/3889))
1214
- Changed `default_ttl` and `analytical_storage_ttl` fields on `ContainerProperties` from `Option<Duration>` to `TimeToLive`, a new enum with variants `Forever`, `NoDefault`, and `Seconds(u32)`, to correctly handle the `-1` wire value (TTL enabled with no default expiration).
1315
- `DatabaseClient::container_client()` now returns `azure_core::Result<ContainerClient>`, eagerly resolving container metadata (RID, partition key definition) at construction time. ([#4005](https://github.com/Azure/azure-sdk-for-rust/pull/4005))
@@ -19,7 +21,6 @@
1921

2022
### Other Changes
2123

22-
2324
## 0.31.0 (2026-02-25)
2425

2526
### Features Added
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# Response Metadata Type System Design Spec
2+
3+
## Motivation
4+
5+
The SDK needs a type-safe way to expose operation-specific response metadata.
6+
Different operations return different headers (e.g. queries return index metrics,
7+
point operations return ETags). The type system should prevent callers from
8+
accessing headers that don't apply to their operation.
9+
10+
## Design (Wrapper Types with Composition)
11+
12+
### Point operation wrappers
13+
14+
Each operation category gets its own response wrapper that composes over
15+
the internal `CosmosResponse<T>`:
16+
17+
```rust
18+
/// Point item operations (create, read, replace, upsert, delete).
19+
pub struct ItemResponse<T> {
20+
response: CosmosResponse<T>,
21+
etag: Option<Etag>, // azure_core::http::Etag
22+
}
23+
24+
/// Resource management operations (databases, containers, throughput).
25+
pub struct ResourceResponse<T> {
26+
response: CosmosResponse<T>,
27+
}
28+
29+
/// Transactional batch operations.
30+
pub struct BatchResponse {
31+
response: CosmosResponse<TransactionalBatchResponse>,
32+
etag: Option<Etag>,
33+
}
34+
```
35+
36+
### Feed page composition
37+
38+
`FeedPage<T>` is a generic, reusable page type for any feed operation.
39+
`QueryFeedPage<T>` composes over it, adding query-specific fields:
40+
41+
```rust
42+
/// Generic feed page — used for any feed operation (queries, read-many,
43+
/// change feed, offers, etc.).
44+
pub struct FeedPage<T> {
45+
items: Vec<T>,
46+
continuation: Option<String>,
47+
raw_headers: Headers,
48+
headers: CosmosResponseHeaders,
49+
diagnostics: CosmosDiagnostics,
50+
}
51+
52+
/// Query-specific feed page — wraps FeedPage and adds query metadata.
53+
pub struct QueryFeedPage<T> {
54+
page: FeedPage<T>,
55+
index_metrics: Option<String>,
56+
query_metrics: Option<String>,
57+
}
58+
```
59+
60+
### Common accessors (on all wrapper types)
61+
62+
- `status()` / `headers()` — HTTP response data
63+
- `request_charge()` — RU consumption
64+
- `session_token()` — session token for consistency
65+
- `diagnostics()``CosmosDiagnostics` (activity ID, server duration)
66+
- `into_body()` / `into_model()` — consume response body
67+
68+
### Operation-specific accessors
69+
70+
| Type | Extra methods |
71+
|------|--------------|
72+
| `ItemResponse<T>` | `etag() -> Option<&Etag>` |
73+
| `ResourceResponse<T>` | (none — future-proof) |
74+
| `BatchResponse` | `etag() -> Option<&Etag>` |
75+
| `FeedPage<T>` | (common methods only) |
76+
| `QueryFeedPage<T>` | `index_metrics() -> Option<&str>`, `query_metrics() -> Option<&str>` |
77+
78+
### Decisions made
79+
80+
1. **Wrapper types** preferred over generic metadata params.
81+
2. **`CosmosResponse<T>` is `pub(crate)`** — wrapper types are the public API.
82+
3. **`QueryFeedPage<T>` composes over `FeedPage<T>`** — query-specific fields on the wrapper.
83+
4. **`FeedPage<T>` is public** — reusable for future read-many, change feed, etc.
84+
5. **`FeedItemIterator<T>` and `FeedPageIterator<T>`** yield `QueryFeedPage<T>` for queries.
85+
6. **ETags use `azure_core::http::Etag`** — not raw strings.
86+
7. **`CosmosDiagnostics`** is universal, available on all response types via `diagnostics()`.
87+
88+
### Open questions
89+
90+
None — all design decisions resolved.

sdk/cosmos/azure_data_cosmos/src/clients/container_client.rs

Lines changed: 32 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,14 @@
33

44
use crate::{
55
clients::OffersClient,
6-
models::{ContainerProperties, CosmosResponse, ThroughputProperties},
6+
models::{
7+
BatchResponse, ContainerProperties, CosmosResponse, ItemResponse, ResourceResponse,
8+
ThroughputProperties,
9+
},
710
options::{BatchOptions, QueryOptions, ReadContainerOptions},
811
pipeline::GatewayPipeline,
912
resource_context::{ResourceLink, ResourceType},
10-
transactional_batch::{TransactionalBatch, TransactionalBatchResponse},
13+
transactional_batch::TransactionalBatch,
1114
DeleteContainerOptions, FeedItemIterator, ItemOptions, PartitionKey, Query,
1215
ReplaceContainerOptions, ThroughputOptions,
1316
};
@@ -106,15 +109,15 @@ impl ContainerClient {
106109
reason = "The 'options' parameter may be used in the future"
107110
)]
108111
options: Option<ReadContainerOptions>,
109-
) -> azure_core::Result<CosmosResponse<ContainerProperties>> {
112+
) -> azure_core::Result<ResourceResponse<ContainerProperties>> {
110113
let cosmos_request =
111114
CosmosRequest::builder(OperationType::Read, self.link.clone()).build()?;
112115
let response: CosmosResponse<ContainerProperties> = self
113116
.container_connection
114117
.send(cosmos_request, Context::default())
115118
.await?;
116119

117-
Ok(response)
120+
Ok(ResourceResponse::new(response))
118121
}
119122

120123
/// Updates the indexing policy of the container.
@@ -150,13 +153,14 @@ impl ContainerClient {
150153
reason = "The 'options' parameter may be used in the future"
151154
)]
152155
options: Option<ReplaceContainerOptions>,
153-
) -> azure_core::Result<CosmosResponse<ContainerProperties>> {
156+
) -> azure_core::Result<ResourceResponse<ContainerProperties>> {
154157
let cosmos_request = CosmosRequest::builder(OperationType::Replace, self.link.clone())
155158
.json(&properties)
156159
.build()?;
157160
self.container_connection
158161
.send(cosmos_request, Context::default())
159162
.await
163+
.map(ResourceResponse::new)
160164
}
161165

162166
/// Reads container throughput properties, if any.
@@ -199,7 +203,7 @@ impl ContainerClient {
199203
&self,
200204
throughput: ThroughputProperties,
201205
options: Option<ThroughputOptions>,
202-
) -> azure_core::Result<CosmosResponse<ThroughputProperties>> {
206+
) -> azure_core::Result<ResourceResponse<ThroughputProperties>> {
203207
#[allow(
204208
unused_variables,
205209
reason = "The 'options' variable may be used in the future"
@@ -214,7 +218,10 @@ impl ContainerClient {
214218
.expect("service should always return a '_rid' for a container");
215219

216220
let offers_client = OffersClient::new(self.pipeline.clone(), resource_id);
217-
offers_client.replace(Context::default(), throughput).await
221+
offers_client
222+
.replace(Context::default(), throughput)
223+
.await
224+
.map(ResourceResponse::new)
218225
}
219226

220227
/// Deletes this container.
@@ -231,12 +238,13 @@ impl ContainerClient {
231238
reason = "The 'options' parameter may be used in the future"
232239
)]
233240
options: Option<DeleteContainerOptions>,
234-
) -> azure_core::Result<CosmosResponse<()>> {
241+
) -> azure_core::Result<ResourceResponse<()>> {
235242
let cosmos_request =
236243
CosmosRequest::builder(OperationType::Delete, self.link.clone()).build()?;
237244
self.container_connection
238245
.send(cosmos_request, Context::default())
239246
.await
247+
.map(ResourceResponse::new)
240248
}
241249

242250
/// Creates a new item in the container.
@@ -274,7 +282,7 @@ impl ContainerClient {
274282
///
275283
/// By default, the newly created item is *not* returned in the HTTP response.
276284
/// If you want the new item to be returned, set the [`ItemOptions::with_content_response_on_write_enabled()`] option to `true`.
277-
/// You can deserialize the returned item by retrieving the [`ResponseBody`](azure_core::http::response::ResponseBody) using [`CosmosResponse::into_body`] and then calling [`ResponseBody::json`](azure_core::http::response::ResponseBody::json), like this:
285+
/// You can deserialize the returned item by retrieving the [`ResponseBody`](azure_core::http::response::ResponseBody) using [`ItemResponse::into_body`] and then calling [`ResponseBody::json`](azure_core::http::response::ResponseBody::json), like this:
278286
///
279287
/// ```rust,no_run
280288
/// use azure_data_cosmos::ItemOptions;
@@ -307,7 +315,7 @@ impl ContainerClient {
307315
partition_key: impl Into<PartitionKey>,
308316
item: T,
309317
options: Option<ItemOptions>,
310-
) -> azure_core::Result<CosmosResponse<()>> {
318+
) -> azure_core::Result<ItemResponse<()>> {
311319
let options = options.clone().unwrap_or_default();
312320
let excluded_regions = options.excluded_regions.clone();
313321
let mut cosmos_request =
@@ -321,6 +329,7 @@ impl ContainerClient {
321329
self.container_connection
322330
.send(cosmos_request, Context::default())
323331
.await
332+
.map(ItemResponse::new)
324333
}
325334

326335
/// Replaces an existing item in the container.
@@ -359,7 +368,7 @@ impl ContainerClient {
359368
///
360369
/// By default, the replaced item is *not* returned in the HTTP response.
361370
/// If you want the replaced item to be returned, set the [`ItemOptions::with_content_response_on_write_enabled()`] option to `true`.
362-
/// You can deserialize the returned item by retrieving the [`ResponseBody`](azure_core::http::response::ResponseBody) using [`CosmosResponse::into_body`] and then calling [`ResponseBody::json`](azure_core::http::response::ResponseBody::json), like this:
371+
/// You can deserialize the returned item by retrieving the [`ResponseBody`](azure_core::http::response::ResponseBody) using [`ItemResponse::into_body`] and then calling [`ResponseBody::json`](azure_core::http::response::ResponseBody::json), like this:
363372
///
364373
/// ```rust,no_run
365374
/// use azure_data_cosmos::ItemOptions;
@@ -392,7 +401,7 @@ impl ContainerClient {
392401
item_id: &str,
393402
item: T,
394403
options: Option<ItemOptions>,
395-
) -> azure_core::Result<CosmosResponse<()>> {
404+
) -> azure_core::Result<ItemResponse<()>> {
396405
let link = self.items_link.item(item_id);
397406
let options = options.clone().unwrap_or_default();
398407
let excluded_regions = options.excluded_regions.clone();
@@ -406,6 +415,7 @@ impl ContainerClient {
406415
self.container_connection
407416
.send(cosmos_request, Context::default())
408417
.await
418+
.map(ItemResponse::new)
409419
}
410420

411421
/// Creates or replaces an item in the container.
@@ -447,7 +457,7 @@ impl ContainerClient {
447457
///
448458
/// By default, the created/replaced item is *not* returned in the HTTP response.
449459
/// If you want the created/replaced item to be returned, set the [`ItemOptions::with_content_response_on_write_enabled()`] option to `true`.
450-
/// You can deserialize the returned item by retrieving the [`ResponseBody`](azure_core::http::response::ResponseBody) using [`CosmosResponse::into_body`] and then calling [`ResponseBody::json`](azure_core::http::response::ResponseBody::json), like this:
460+
/// You can deserialize the returned item by retrieving the [`ResponseBody`](azure_core::http::response::ResponseBody) using [`ItemResponse::into_body`] and then calling [`ResponseBody::json`](azure_core::http::response::ResponseBody::json), like this:
451461
///
452462
/// ```rust,no_run
453463
/// use azure_data_cosmos::ItemOptions;
@@ -479,7 +489,7 @@ impl ContainerClient {
479489
partition_key: impl Into<PartitionKey>,
480490
item: T,
481491
options: Option<ItemOptions>,
482-
) -> azure_core::Result<CosmosResponse<()>> {
492+
) -> azure_core::Result<ItemResponse<()>> {
483493
let options = options.clone().unwrap_or_default();
484494
let excluded_regions = options.excluded_regions.clone();
485495
let mut cosmos_request =
@@ -493,7 +503,8 @@ impl ContainerClient {
493503
return self
494504
.container_connection
495505
.send(cosmos_request, Context::default())
496-
.await;
506+
.await
507+
.map(ItemResponse::new);
497508
}
498509

499510
/// Reads a specific item from the container.
@@ -532,7 +543,7 @@ impl ContainerClient {
532543
partition_key: impl Into<PartitionKey>,
533544
item_id: &str,
534545
options: Option<ItemOptions>,
535-
) -> azure_core::Result<CosmosResponse<T>> {
546+
) -> azure_core::Result<ItemResponse<T>> {
536547
let mut options = options.unwrap_or_default();
537548

538549
// Read APIs should always return the item, ignoring whatever the user set.
@@ -549,6 +560,7 @@ impl ContainerClient {
549560
self.container_connection
550561
.send(cosmos_request, Context::default())
551562
.await
563+
.map(|r| ItemResponse::new(r))
552564
}
553565

554566
/// Deletes an item from the container.
@@ -577,7 +589,7 @@ impl ContainerClient {
577589
partition_key: impl Into<PartitionKey>,
578590
item_id: &str,
579591
options: Option<ItemOptions>,
580-
) -> azure_core::Result<CosmosResponse<()>> {
592+
) -> azure_core::Result<ItemResponse<()>> {
581593
let link = self.items_link.item(item_id);
582594
let options = options.clone().unwrap_or_default();
583595
let excluded_regions = options.excluded_regions.clone();
@@ -590,6 +602,7 @@ impl ContainerClient {
590602
self.container_connection
591603
.send(cosmos_request, Context::default())
592604
.await
605+
.map(ItemResponse::new)
593606
}
594607

595608
/// Executes a single-partition query against items in the container.
@@ -726,7 +739,7 @@ impl ContainerClient {
726739
&self,
727740
batch: TransactionalBatch,
728741
options: Option<BatchOptions>,
729-
) -> azure_core::Result<CosmosResponse<TransactionalBatchResponse>> {
742+
) -> azure_core::Result<BatchResponse> {
730743
let options = options.unwrap_or_default();
731744
let partition_key = batch.partition_key().clone();
732745

@@ -740,5 +753,6 @@ impl ContainerClient {
740753
self.container_connection
741754
.send(cosmos_request, Context::default())
742755
.await
756+
.map(BatchResponse::new)
743757
}
744758
}

0 commit comments

Comments
 (0)