From 7c918b63a76aba03b4a21186d888b18202d7b08a Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Fri, 1 May 2026 19:51:14 -0700 Subject: [PATCH 1/2] RE1-T115 S3 fix --- .../Web/Tts/S3StorageServiceTests.cs | 72 +++++++++++++++++-- .../Services/S3StorageService.cs | 72 +++++++++++++++++++ 2 files changed, 138 insertions(+), 6 deletions(-) diff --git a/Tests/Resgrid.Tests/Web/Tts/S3StorageServiceTests.cs b/Tests/Resgrid.Tests/Web/Tts/S3StorageServiceTests.cs index 3aa45a2f..2b3d5685 100644 --- a/Tests/Resgrid.Tests/Web/Tts/S3StorageServiceTests.cs +++ b/Tests/Resgrid.Tests/Web/Tts/S3StorageServiceTests.cs @@ -55,6 +55,39 @@ public async Task upload_async_should_buffer_non_seekable_stream_for_retries() s3Client.Verify(x => x.PutObjectAsync(It.IsAny(), It.IsAny()), Times.Exactly(2)); } + [Test] + public async Task exists_async_should_fall_back_to_presigned_head_when_metadata_response_is_malformed() + { + var s3Client = new Mock(MockBehavior.Strict); + s3Client + .Setup(x => x.GetObjectMetadataAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new FormatException("bad metadata expiration header")); + s3Client + .Setup(x => x.GetPreSignedURL(It.IsAny())) + .Returns(request => + { + request.BucketName.Should().Be("tts-bucket"); + request.Key.Should().Be("tts/audio.wav"); + request.Verb.Should().Be(HttpVerb.HEAD); + return "https://upload.example.com/tts/audio.wav?signature=head"; + }); + + var handler = new RecordingHttpMessageHandler((request, _) => + { + request.Method.Should().Be(HttpMethod.Head); + request.RequestUri.Should().Be(new Uri("https://upload.example.com/tts/audio.wav?signature=head")); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + }); + var service = CreateService(s3Client.Object, handler); + + var exists = await service.ExistsAsync("tts/audio.wav", CancellationToken.None); + + exists.Should().BeTrue(); + handler.Requests.Should().HaveCount(1); + s3Client.Verify(x => x.GetObjectMetadataAsync(It.Is(request => request.BucketName == "tts-bucket" && request.Key == "tts/audio.wav"), It.IsAny()), Times.Once); + s3Client.Verify(x => x.GetPreSignedURL(It.IsAny()), Times.Once); + } + [Test] public async Task upload_async_should_treat_format_exception_as_success_when_object_exists_after_upload() { @@ -91,15 +124,39 @@ public async Task upload_async_should_fall_back_to_presigned_put_when_metadata_r .ThrowsAsync(new FormatException("bad metadata expiration header")); s3Client .Setup(x => x.GetPreSignedURL(It.IsAny())) - .Returns("https://upload.example.com/tts/audio.wav?signature=metadata-format"); + .Returns(request => + { + request.BucketName.Should().Be("tts-bucket"); + request.Key.Should().Be("tts/audio.wav"); + + return request.Verb switch + { + HttpVerb.HEAD => "https://upload.example.com/tts/audio.wav?signature=metadata-head", + HttpVerb.PUT => "https://upload.example.com/tts/audio.wav?signature=metadata-put", + _ => throw new AssertionException($"Unexpected presigned verb {request.Verb}") + }; + }); + var headRequests = 0; + var putRequests = 0; var handler = new RecordingHttpMessageHandler(async (request, cancellationToken) => { + request.RequestUri.Should().NotBeNull(); + + if (request.Method == HttpMethod.Head) + { + headRequests++; + request.RequestUri.Should().Be(new Uri("https://upload.example.com/tts/audio.wav?signature=metadata-head")); + return new HttpResponseMessage(HttpStatusCode.NotFound); + } + + putRequests++; + request.Method.Should().Be(HttpMethod.Put); + request.RequestUri.Should().Be(new Uri("https://upload.example.com/tts/audio.wav?signature=metadata-put")); + var body = await request.Content!.ReadAsByteArrayAsync(cancellationToken); body.Should().Equal(2, 4, 6, 8); - request.Method.Should().Be(HttpMethod.Put); - request.RequestUri.Should().Be(new Uri("https://upload.example.com/tts/audio.wav?signature=metadata-format")); - request.Content!.Headers.ContentType!.MediaType.Should().Be("audio/wav"); + request.Content.Headers.ContentType!.MediaType.Should().Be("audio/wav"); return new HttpResponseMessage(HttpStatusCode.OK); }); @@ -109,9 +166,12 @@ public async Task upload_async_should_fall_back_to_presigned_put_when_metadata_r await service.UploadAsync("tts/audio.wav", content, "audio/wav", CancellationToken.None); - handler.Requests.Should().HaveCount(1); + headRequests.Should().Be(1); + putRequests.Should().Be(1); + handler.Requests.Should().HaveCount(2); s3Client.Verify(x => x.GetObjectMetadataAsync(It.IsAny(), It.IsAny()), Times.Once); - s3Client.Verify(x => x.GetPreSignedURL(It.IsAny()), Times.Once); + s3Client.Verify(x => x.GetPreSignedURL(It.Is(request => request.Verb == HttpVerb.HEAD)), Times.Once); + s3Client.Verify(x => x.GetPreSignedURL(It.Is(request => request.Verb == HttpVerb.PUT)), Times.Once); } [Test] diff --git a/Web/Resgrid.Web.Tts/Services/S3StorageService.cs b/Web/Resgrid.Web.Tts/Services/S3StorageService.cs index b5c9424c..bc425243 100644 --- a/Web/Resgrid.Web.Tts/Services/S3StorageService.cs +++ b/Web/Resgrid.Web.Tts/Services/S3StorageService.cs @@ -52,6 +52,15 @@ await ExecuteWithRetryAsync( { return false; } + catch (FormatException ex) + { + _logger.LogWarning( + ex, + "The S3 client could not parse the metadata response for {ObjectKey}. Falling back to a presigned HEAD request.", + objectKey); + + return await ExistsWithPresignedHeadAsync(objectKey, cancellationToken); + } } public async Task UploadAsync(string objectKey, Stream content, string contentType, CancellationToken cancellationToken) @@ -230,6 +239,69 @@ private async Task UploadWithPresignedUrlAsync(string objectKey, Stream content, throw new InvalidOperationException($"Presigned PUT upload retry loop terminated unexpectedly for {objectKey}."); } + private string CreatePresignedHeadUrl(string objectKey) + { + return _s3Client.GetPreSignedURL(new GetPreSignedUrlRequest + { + BucketName = _options.Bucket, + Key = objectKey, + Verb = HttpVerb.HEAD, + Expires = DateTime.UtcNow.AddMinutes(PresignedPutUrlExpiryMinutes) + }); + } + + private async Task ExistsWithPresignedHeadAsync(string objectKey, CancellationToken cancellationToken) + { + var client = _httpClientFactory.CreateClient(nameof(S3StorageService)); + + for (var attempt = 1; attempt <= MaxRetryAttempts; attempt++) + { + using var request = new HttpRequestMessage(HttpMethod.Head, CreatePresignedHeadUrl(objectKey)); + + try + { + using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + + if (response.StatusCode == HttpStatusCode.NotFound) + { + return false; + } + + if (response.IsSuccessStatusCode) + { + return true; + } + + var exception = new HttpRequestException( + $"Presigned HEAD existence check for {objectKey} failed with status code {(int)response.StatusCode}.", + null, + response.StatusCode); + + if (attempt < MaxRetryAttempts && IsTransientStatusCode(response.StatusCode)) + { + await DelayRetryAsync($"checking existence of {objectKey} via presigned HEAD", attempt, exception, cancellationToken); + continue; + } + + response.EnsureSuccessStatusCode(); + } + catch (HttpRequestException ex) when (attempt < MaxRetryAttempts && (!ex.StatusCode.HasValue || IsTransientStatusCode(ex.StatusCode.Value))) + { + await DelayRetryAsync($"checking existence of {objectKey} via presigned HEAD", attempt, ex, cancellationToken); + } + catch (IOException ex) when (attempt < MaxRetryAttempts) + { + await DelayRetryAsync($"checking existence of {objectKey} via presigned HEAD", attempt, ex, cancellationToken); + } + catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested && attempt < MaxRetryAttempts) + { + await DelayRetryAsync($"checking existence of {objectKey} via presigned HEAD", attempt, ex, cancellationToken); + } + } + + throw new InvalidOperationException($"Presigned HEAD existence check retry loop terminated unexpectedly for {objectKey}."); + } + private string CreatePresignedPutUrl(string objectKey, string contentType) { return _s3Client.GetPreSignedURL(new GetPreSignedUrlRequest From 0b145ca5102aab11be626c0e0fe7e3ea81c2740a Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Fri, 1 May 2026 20:31:49 -0700 Subject: [PATCH 2/2] RE1-T115 More S3 fixes --- Directory.Build.props | 8 ++++++++ .../Services/DepartmentSettingsServiceTtsLanguageTests.cs | 1 + .../Web/Tts/TtsConfigurationRegistrationTests.cs | 1 + .../Console/Tasks/TtsStaticPromptRefreshTaskTests.cs | 1 + 4 files changed, 11 insertions(+) create mode 100644 Directory.Build.props diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 00000000..d85b83f7 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,8 @@ + + + unix + windows + obj/$(ResgridHostOS)/ + $(DefaultItemExcludes);obj/** + + diff --git a/Tests/Resgrid.Tests/Services/DepartmentSettingsServiceTtsLanguageTests.cs b/Tests/Resgrid.Tests/Services/DepartmentSettingsServiceTtsLanguageTests.cs index 6c283d1b..6055e2e9 100644 --- a/Tests/Resgrid.Tests/Services/DepartmentSettingsServiceTtsLanguageTests.cs +++ b/Tests/Resgrid.Tests/Services/DepartmentSettingsServiceTtsLanguageTests.cs @@ -12,6 +12,7 @@ namespace Resgrid.Tests.Services { [TestFixture] + [NonParallelizable] public class DepartmentSettingsServiceTtsLanguageTests { private Mock _departmentSettingsRepository; diff --git a/Tests/Resgrid.Tests/Web/Tts/TtsConfigurationRegistrationTests.cs b/Tests/Resgrid.Tests/Web/Tts/TtsConfigurationRegistrationTests.cs index b60395ef..af731cd4 100644 --- a/Tests/Resgrid.Tests/Web/Tts/TtsConfigurationRegistrationTests.cs +++ b/Tests/Resgrid.Tests/Web/Tts/TtsConfigurationRegistrationTests.cs @@ -8,6 +8,7 @@ namespace Resgrid.Tests.Web.Tts { [TestFixture] + [NonParallelizable] public class TtsConfigurationRegistrationTests { private string _originalS3AccessKey; diff --git a/Tests/Resgrid.Tests/Workers/Console/Tasks/TtsStaticPromptRefreshTaskTests.cs b/Tests/Resgrid.Tests/Workers/Console/Tasks/TtsStaticPromptRefreshTaskTests.cs index 225388ba..686e4e1d 100644 --- a/Tests/Resgrid.Tests/Workers/Console/Tasks/TtsStaticPromptRefreshTaskTests.cs +++ b/Tests/Resgrid.Tests/Workers/Console/Tasks/TtsStaticPromptRefreshTaskTests.cs @@ -17,6 +17,7 @@ namespace Resgrid.Tests.Workers.Console.Tasks { [TestFixture] + [NonParallelizable] public class TtsStaticPromptRefreshTaskTests { private static readonly FieldInfo WorkerBootstrapperContainerField = typeof(Resgrid.Workers.Framework.Bootstrapper)