From 396e1a12c548b9a29e04c9c0f0117350a0e7dc54 Mon Sep 17 00:00:00 2001 From: Matt Burton Date: Wed, 27 May 2026 11:23:37 -0400 Subject: [PATCH] fix: use TotalSeconds/TotalMilliseconds in WaitAndRetry timeout comparison TimeSpan.Seconds returns only the seconds component (0-59), not the total seconds. For timeouts >= 60 seconds (e.g. the default 60s), .Seconds is 0 because it's exactly 1 minute. This caused WaitAndRetry to always throw a timeout exception instead of retrying, with the misleading message 'The request took longer than the 0 milliseconds allowed'. The fix uses .TotalSeconds and .TotalMilliseconds which return the full value regardless of magnitude. Added regression tests for timeouts >= 60s and correct millisecond reporting in error messages. Bumps version to 3.2.1. --- CHANGELOG.md | 6 +++ README.md | 2 +- ShipEngineSDK.Test/NetworkTimeoutsTest.cs | 56 +++++++++++++++++++++++ ShipEngineSDK/ShipEngineClient.cs | 4 +- ShipEngineSDK/ShipEngineSDK.csproj | 2 +- openapitools.json | 2 +- 6 files changed, 67 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 435a61cc..7d132fce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -241,3 +241,9 @@ Fixed handling of No Content responses - Updated to latest spec - Fixed serialization so that nullable fields are not required in deserialization, and not emitted during serialization if they are null. + +## 3.2.1 + +### Changed + +- Fixed retry timeout handling to use the configured timeout value correctly diff --git a/README.md b/README.md index 6ebebe9b..4bca37a3 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ var shipengine = new ShipEngine("___YOUR_API_KEY_HERE__"); This C# SDK is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: - API version: 1.1.202603171403 -- SDK version: 3.1.0 +- SDK version: 3.2.1 - Generator version: 7.7.0 - Build package: org.openapitools.codegen.languages.CSharpClientCodegen For more information, please visit [https://www.shipengine.com/contact/](https://www.shipengine.com/contact/) diff --git a/ShipEngineSDK.Test/NetworkTimeoutsTest.cs b/ShipEngineSDK.Test/NetworkTimeoutsTest.cs index 78014bd5..49d3ffb2 100644 --- a/ShipEngineSDK.Test/NetworkTimeoutsTest.cs +++ b/ShipEngineSDK.Test/NetworkTimeoutsTest.cs @@ -171,5 +171,61 @@ public async Task RetryAfterIsGreaterThanTimeoutSetting() Assert.Equal(ErrorCode.Timeout, ex.ErrorCode); Assert.Equal("204c855f-dcc0-4270-ba12-c585fc5ef4bf", ex.RequestId); } + + // Regression: TimeSpan.Seconds returns only the seconds component (0-59), + // so a timeout of 60+ seconds would incorrectly compare as 0 and always + // throw a timeout exception instead of retrying. + [Fact] + public async Task RetryWorksWithTimeoutGreaterThanOrEqualTo60Seconds() + { + var config = new Config(apiKey: "TEST_bTYAskEX6tD7vv6u/cZ/M4LaUSWBJ219+8S1jgFcnkk", timeout: TimeSpan.FromSeconds(60), retries: 1); + var mockShipEngineFixture = new MockShipEngineFixture(config); + + mockShipEngineFixture.MockHandler.Protected() + .SetupSequence>( + "SendAsync", + ItExpr.Is(m => + m.Method == HttpMethod.Put && + m.RequestUri.AbsolutePath == "/v1/labels/se-1234/void"), + ItExpr.IsAny()) + .Returns(Task.FromResult(RateLimitResponseMessage)) + .Returns(Task.FromResult( + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(VoidLabelResponse) + } + )); + + // Should retry successfully, not throw a timeout exception + await mockShipEngineFixture.ShipEngine.VoidLabelWithLabelId("se-1234"); + + mockShipEngineFixture.AssertRequest(HttpMethod.Put, "/v1/labels/se-1234/void", numberOfCalls: 2); + } + + [Fact] + public async Task TimeoutMessageShowsCorrectMillisecondsForLargeTimeouts() + { + // RetryAfter header is 1 second; set timeout to 0.5s so it triggers the timeout path + var config = new Config(apiKey: "TEST_bTYAskEX6tD7vv6u/cZ/M4LaUSWBJ219+8S1jgFcnkk", timeout: TimeSpan.FromMilliseconds(1500), retries: 1); + var mockShipEngineFixture = new MockShipEngineFixture(config); + + var rateLimitWithHighRetryAfter = new HttpResponseMessage((HttpStatusCode)429); + rateLimitWithHighRetryAfter.Content = new StringContent(rateLimitResponse); + rateLimitWithHighRetryAfter.Headers.Add("RetryAfter", "2"); + + mockShipEngineFixture.MockHandler.Protected() + .SetupSequence>( + "SendAsync", + ItExpr.Is(m => + m.Method == HttpMethod.Put && + m.RequestUri.AbsolutePath == "/v1/labels/se-1234/void"), + ItExpr.IsAny()) + .Returns(Task.FromResult(rateLimitWithHighRetryAfter)); + + var ex = await Assert.ThrowsAsync(async () => await mockShipEngineFixture.ShipEngine.VoidLabelWithLabelId("se-1234")); + + Assert.Equal("The request took longer than the 1500 milliseconds allowed", ex.Message); + Assert.Equal(ErrorCode.Timeout, ex.ErrorCode); + } } } \ No newline at end of file diff --git a/ShipEngineSDK/ShipEngineClient.cs b/ShipEngineSDK/ShipEngineClient.cs index ee3543d1..d975afbf 100644 --- a/ShipEngineSDK/ShipEngineClient.cs +++ b/ShipEngineSDK/ShipEngineClient.cs @@ -300,10 +300,10 @@ private async Task WaitAndRetry(HttpResponseMessage? response, Config config, Sh retryAfter = 5; } - if (config.Timeout.Seconds < retryAfter) + if (config.Timeout.TotalSeconds < retryAfter) { throw new ShipEngineException( - $"The request took longer than the {config.Timeout.Milliseconds} milliseconds allowed", + $"The request took longer than the {config.Timeout.TotalMilliseconds} milliseconds allowed", ErrorSource.Shipengine, ErrorType.System, ErrorCode.Timeout, diff --git a/ShipEngineSDK/ShipEngineSDK.csproj b/ShipEngineSDK/ShipEngineSDK.csproj index 47d91a4d..fc0744d2 100644 --- a/ShipEngineSDK/ShipEngineSDK.csproj +++ b/ShipEngineSDK/ShipEngineSDK.csproj @@ -4,7 +4,7 @@ ShipEngine sdk;rest;api;shipping;rates;label;tracking;cost;address;validation;normalization;fedex;ups;usps; - 3.2.0 + 3.2.1 ShipEngine ShipEngine The official ShipEngine C# SDK for .NET diff --git a/openapitools.json b/openapitools.json index 2b16ed48..805a6b52 100644 --- a/openapitools.json +++ b/openapitools.json @@ -14,7 +14,7 @@ "ignoreFileOverride": "./.openapi-generator-ignore", "library": "generichost", "additionalProperties": { - "packageVersion": "3.2.0", + "packageVersion": "3.2.1", "targetFramework": "netstandard2.0", "validatable": false, "sourceFolder": "",