Skip to content

Commit 525d0ea

Browse files
Batch: Fixes null ErrorMessage when promoting status from MultiStatus response (#5650)
## Summary Fixes #5649 When a transactional batch response returns **207 MultiStatus** and the SDK promotes the status code from a failing operation (e.g., 409 Conflict), the \TransactionalBatchResponse.ErrorMessage\ was always \ ull\. This is because only \StatusCode\ and \SubStatusCode\ were promoted from the failing operation — the \ErrorMessage\ remained sourced from the outer 207 response, which has no error message. ## Root Cause In \TransactionalBatchResponse.PopulateFromContentAsync()\, the status promotion logic (lines 365–379) correctly promotes \ esponseStatusCode\ and \ esponseSubStatusCode\ from the first failing operation result, but the \ErrorMessage\ passed to the \TransactionalBatchResponse\ constructor was still \ esponseMessage.ErrorMessage\ — the outer 207 MultiStatus message's error, which is always `null`. \\\csharp // Before (bug): TransactionalBatchResponse response = new TransactionalBatchResponse( responseStatusCode, // 409 - promoted from operation ✅ responseSubStatusCode, // promoted from operation ✅ responseMessage.ErrorMessage, // null from outer 207 response ❌ ...); \\\ ## Fix When promoting the status code from a failing operation, also read the error message from the failing operation's \ResourceStream\ (which contains the server's error body for that specific operation) and use it as the promoted \ErrorMessage\. \\\csharp // After (fix): if (result.ResourceStream != null) { using (StreamReader reader = new StreamReader( result.ResourceStream, encoding: Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 1024, leaveOpen: true)) { responseErrorMessage = reader.ReadToEnd(); result.ResourceStream.Position = 0; // Reset so stream is still usable } } \\\ The stream position is reset after reading so the \ResourceStream\ remains available to consumers who access individual operation results. ## Testing Added \BatchResponseDeserializationPromotesErrorMessageAsync\ test that: 1. Creates a 409 Conflict operation result with a \ResourceStream\ containing an error body 2. Creates a second operation result with 424 FailedDependency (as the server would) 3. Wraps them in a 207 MultiStatus response 4. Calls \FromResponseMessageAsync\ with \shouldPromoteOperationStatus=true\ 5. Asserts \StatusCode\ is promoted to 409 6. **Asserts \ErrorMessage\ is promoted from the operation's ResourceStream** (not null) All 103 existing batch-related unit tests continue to pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 4cbe83b commit 525d0ea

2 files changed

Lines changed: 65 additions & 2 deletions

File tree

Microsoft.Azure.Cosmos/src/Batch/TransactionalBatchResponse.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,7 @@ private static async Task<TransactionalBatchResponse> PopulateFromContentAsync(
361361

362362
HttpStatusCode responseStatusCode = responseMessage.StatusCode;
363363
SubStatusCodes responseSubStatusCode = responseMessage.Headers.SubStatusCode;
364+
string responseErrorMessage = responseMessage.ErrorMessage;
364365

365366
// Promote the operation error status as the Batch response error status if we have a MultiStatus response
366367
// to provide users with status codes they are used to.
@@ -373,6 +374,16 @@ private static async Task<TransactionalBatchResponse> PopulateFromContentAsync(
373374
{
374375
responseStatusCode = result.StatusCode;
375376
responseSubStatusCode = result.SubStatusCode;
377+
378+
if (result.ResourceStream != null)
379+
{
380+
using (StreamReader reader = new StreamReader(result.ResourceStream, encoding: System.Text.Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 1024, leaveOpen: true))
381+
{
382+
responseErrorMessage = reader.ReadToEnd();
383+
result.ResourceStream.Position = 0;
384+
}
385+
}
386+
376387
break;
377388
}
378389
}
@@ -381,7 +392,7 @@ private static async Task<TransactionalBatchResponse> PopulateFromContentAsync(
381392
TransactionalBatchResponse response = new TransactionalBatchResponse(
382393
responseStatusCode,
383394
responseSubStatusCode,
384-
responseMessage.ErrorMessage,
395+
responseErrorMessage,
385396
responseMessage.Headers,
386397
trace,
387398
serverRequest.Operations,

Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/Batch/BatchSchemaTests.cs

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,59 @@ public async Task BatchResponseDeserializationAsync()
177177
Assert.IsTrue(comparer.Equals(results[1], batchResponse[1]));
178178
}
179179

180-
private class ItemBatchOperationEqualityComparer : IEqualityComparer<ItemBatchOperation>
180+
[TestMethod]
181+
[Owner("nalutripician")]
182+
public async Task BatchResponseDeserializationPromotesErrorMessageAsync()
183+
{
184+
string expectedErrorMessage = "{\"Errors\":[\"Resource with specified id or name already exists.\"]}";
185+
byte[] errorBody = System.Text.Encoding.UTF8.GetBytes(expectedErrorMessage);
186+
187+
using CosmosClient cosmosClient = MockCosmosUtil.CreateMockCosmosClient();
188+
ContainerInternal containerCore = (ContainerInlineCore)cosmosClient.GetDatabase("db").GetContainer("cont");
189+
List<TransactionalBatchOperationResult> results = new List<TransactionalBatchOperationResult>
190+
{
191+
new TransactionalBatchOperationResult(HttpStatusCode.Conflict)
192+
{
193+
ResourceStream = new CloneableStream(
194+
internalStream: new MemoryStream(errorBody, index: 0, count: errorBody.Length, writable: false, publiclyVisible: true),
195+
allowUnsafeDataAccess: true),
196+
},
197+
new TransactionalBatchOperationResult(HttpStatusCode.FailedDependency)
198+
};
199+
200+
MemoryStream responseContent = await new BatchResponsePayloadWriter(results).GeneratePayloadAsync();
201+
202+
SinglePartitionKeyServerBatchRequest batchRequest = await SinglePartitionKeyServerBatchRequest.CreateAsync(
203+
partitionKey: Cosmos.PartitionKey.None,
204+
operations: new ArraySegment<ItemBatchOperation>(
205+
new ItemBatchOperation[]
206+
{
207+
new ItemBatchOperation(OperationType.Create, operationIndex: 0, id: "someId", containerCore: containerCore),
208+
new ItemBatchOperation(OperationType.Create, operationIndex: 1, id: "someId2", containerCore: containerCore)
209+
}),
210+
serializerCore: MockCosmosUtil.Serializer,
211+
trace: NoOpTrace.Singleton,
212+
cancellationToken: CancellationToken.None);
213+
214+
ResponseMessage response = new ResponseMessage((HttpStatusCode)StatusCodes.MultiStatus) { Content = responseContent };
215+
response.Headers.Session = Guid.NewGuid().ToString();
216+
response.Headers.ActivityId = Guid.NewGuid().ToString();
217+
218+
TransactionalBatchResponse batchResponse = await TransactionalBatchResponse.FromResponseMessageAsync(
219+
response,
220+
batchRequest,
221+
MockCosmosUtil.Serializer,
222+
true,
223+
NoOpTrace.Singleton,
224+
CancellationToken.None);
225+
226+
Assert.IsNotNull(batchResponse);
227+
Assert.AreEqual(HttpStatusCode.Conflict, batchResponse.StatusCode);
228+
Assert.IsNotNull(batchResponse.ErrorMessage, "ErrorMessage should be promoted from the failing operation's ResourceStream");
229+
Assert.AreEqual(expectedErrorMessage, batchResponse.ErrorMessage);
230+
}
231+
232+
private class ItemBatchOperationEqualityComparer: IEqualityComparer<ItemBatchOperation>
181233
{
182234
public bool Equals(ItemBatchOperation x, ItemBatchOperation y)
183235
{

0 commit comments

Comments
 (0)