From 13e3a223af40bafeae4cc95efebcee23592b2efc Mon Sep 17 00:00:00 2001 From: Mark Wallace <127216156+markwallace-microsoft@users.noreply.github.com> Date: Mon, 8 Jul 2024 17:05:05 +0100 Subject: [PATCH] .Net: Include request data when operation is cancelled (#7119) ### Motivation and Context Closes #7118 When request is cancelled due to configured Timeout on HttpClient, SK will throw `KernelFunctionCanceledException`. This change sets the Url, request payload etc. on the `KernelFunctionCanceledException`. ### Description ### Contribution Checklist - [ ] The code builds clean without any errors or warnings - [ ] The PR follows the [SK Contribution Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md) and the [pre-submission formatting script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts) raises no violations - [ ] All unit tests pass, and I have added new tests where possible - [ ] I didn't break anyone :smile: --- .../RestApiOperationRunner.cs | 8 +++ .../OpenApi/RestApiOperationRunnerTests.cs | 39 +++++++++++++++ .../Plugins/OpenApi/RepairServiceTests.cs | 49 +++++++++++++++++-- 3 files changed, 93 insertions(+), 3 deletions(-) diff --git a/dotnet/src/Functions/Functions.OpenApi/RestApiOperationRunner.cs b/dotnet/src/Functions/Functions.OpenApi/RestApiOperationRunner.cs index b7bc593c76b2..99ff2f276d15 100644 --- a/dotnet/src/Functions/Functions.OpenApi/RestApiOperationRunner.cs +++ b/dotnet/src/Functions/Functions.OpenApi/RestApiOperationRunner.cs @@ -221,6 +221,14 @@ private async Task SendAsync( throw; } + catch (OperationCanceledException ex) + { + ex.Data.Add(HttpRequestMethod, requestMessage.Method.Method); + ex.Data.Add(UrlFull, requestMessage.RequestUri?.ToString()); + ex.Data.Add(HttpRequestBody, payload); + + throw; + } catch (KernelException ex) { ex.Data.Add(HttpRequestMethod, requestMessage.Method.Method); diff --git a/dotnet/src/Functions/Functions.UnitTests/OpenApi/RestApiOperationRunnerTests.cs b/dotnet/src/Functions/Functions.UnitTests/OpenApi/RestApiOperationRunnerTests.cs index b836ec18ed80..fd980398a3ac 100644 --- a/dotnet/src/Functions/Functions.UnitTests/OpenApi/RestApiOperationRunnerTests.cs +++ b/dotnet/src/Functions/Functions.UnitTests/OpenApi/RestApiOperationRunnerTests.cs @@ -1206,6 +1206,38 @@ public async Task ItShouldSetHttpRequestMessageOptionsAsync() Assert.Equal(options.KernelArguments, kernelFunctionContext.Arguments); } + [Fact] + public async Task ItShouldIncludeRequestDataWhenOperationCanceledExceptionIsThrownAsync() + { + // Arrange + this._httpMessageHandlerStub.ExceptionToThrow = new OperationCanceledException(); + + var operation = new RestApiOperation( + "fake-id", + new Uri("https://fake-random-test-host"), + "fake-path", + HttpMethod.Post, + "fake-description", + [], + payload: null + ); + + var arguments = new KernelArguments + { + { "payload", JsonSerializer.Serialize(new { value = "fake-value" }) }, + { "content-type", "application/json" } + }; + + var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object); + + // Act & Assert + var canceledException = await Assert.ThrowsAsync(() => sut.RunAsync(operation, arguments)); + Assert.Equal("The operation was canceled.", canceledException.Message); + Assert.Equal("POST", canceledException.Data["http.request.method"]); + Assert.Equal("https://fake-random-test-host/fake-path", canceledException.Data["url.full"]); + Assert.Equal("{\"value\":\"fake-value\"}", canceledException.Data["http.request.body"]); + } + public class SchemaTestData : IEnumerable { public IEnumerator GetEnumerator() @@ -1302,6 +1334,8 @@ private sealed class HttpMessageHandlerStub : DelegatingHandler public HttpResponseMessage ResponseToReturn { get; set; } + public Exception? ExceptionToThrow { get; set; } + public HttpMessageHandlerStub() { this.ResponseToReturn = new HttpResponseMessage(System.Net.HttpStatusCode.OK) @@ -1312,6 +1346,11 @@ public HttpMessageHandlerStub() protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { + if (this.ExceptionToThrow is not null) + { + throw this.ExceptionToThrow; + } + this.RequestMessage = request; this.RequestContent = request.Content is null ? null : await request.Content.ReadAsByteArrayAsync(cancellationToken); diff --git a/dotnet/src/IntegrationTests/Plugins/OpenApi/RepairServiceTests.cs b/dotnet/src/IntegrationTests/Plugins/OpenApi/RepairServiceTests.cs index f6bcb3c01be8..ac63ac9bcf54 100644 --- a/dotnet/src/IntegrationTests/Plugins/OpenApi/RepairServiceTests.cs +++ b/dotnet/src/IntegrationTests/Plugins/OpenApi/RepairServiceTests.cs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Net.Http; using System.Text.Json; using System.Text.Json.Serialization; @@ -17,7 +18,7 @@ public async Task ValidateInvokingRepairServicePluginAsync() { // Arrange var kernel = new Kernel(); - using var stream = System.IO.File.OpenRead("Plugins/repair-service.json"); + using var stream = System.IO.File.OpenRead("Plugins/OpenApi/repair-service.json"); using HttpClient httpClient = new(); var plugin = await kernel.ImportPluginFromOpenApiAsync( @@ -73,7 +74,7 @@ public async Task HttpOperationExceptionIncludeRequestInfoAsync() { // Arrange var kernel = new Kernel(); - using var stream = System.IO.File.OpenRead("Plugins/repair-service.json"); + using var stream = System.IO.File.OpenRead("Plugins/OpenApi/repair-service.json"); using HttpClient httpClient = new(); var plugin = await kernel.ImportPluginFromOpenApiAsync( @@ -107,12 +108,54 @@ public async Task HttpOperationExceptionIncludeRequestInfoAsync() } } + [Fact(Skip = "This test is for manual verification.")] + public async Task KernelFunctionCanceledExceptionIncludeRequestInfoAsync() + { + // Arrange + var kernel = new Kernel(); + using var stream = System.IO.File.OpenRead("Plugins/OpenApi/repair-service.json"); + using HttpClient httpClient = new(); + + var plugin = await kernel.ImportPluginFromOpenApiAsync( + "RepairService", + stream, + new OpenApiFunctionExecutionParameters(httpClient) { IgnoreNonCompliantErrors = true, EnableDynamicPayload = false }); + + var arguments = new KernelArguments + { + ["payload"] = """{ "title": "Engine oil change", "description": "Need to drain the old engine oil and replace it with fresh oil.", "assignedTo": "", "date": "", "image": "" }""" + }; + + var id = 99999; + + // Update Repair + arguments = new KernelArguments + { + ["payload"] = $"{{ \"id\": {id}, \"assignedTo\": \"Karin Blair\", \"date\": \"2024-04-16\", \"image\": \"https://www.howmuchisit.org/wp-content/uploads/2011/01/oil-change.jpg\" }}" + }; + + try + { + httpClient.Timeout = TimeSpan.FromMilliseconds(10); // Force a timeout + + await plugin["updateRepair"].InvokeAsync(kernel, arguments); + Assert.Fail("Expected KernelFunctionCanceledException"); + } + catch (KernelFunctionCanceledException ex) + { + Assert.Equal("The invocation of function 'updateRepair' was canceled.", ex.Message); + Assert.NotNull(ex.InnerException); + Assert.Equal("Patch", ex.InnerException.Data["http.request.method"]); + Assert.Equal("https://piercerepairsapi.azurewebsites.net/repairs", ex.InnerException.Data["url.full"]); + } + } + [Fact(Skip = "This test is for manual verification.")] public async Task UseDelegatingHandlerAsync() { // Arrange var kernel = new Kernel(); - using var stream = System.IO.File.OpenRead("Plugins/repair-service.json"); + using var stream = System.IO.File.OpenRead("Plugins/OpenApi/repair-service.json"); using var httpHandler = new HttpClientHandler(); using var customHandler = new CustomHandler(httpHandler);