diff --git a/.editorconfig b/.editorconfig index 1b9b94a..1ab806b 100644 --- a/.editorconfig +++ b/.editorconfig @@ -295,6 +295,11 @@ dotnet_style_prefer_conditional_expression_over_return = false:suggestion # teaches substitution boundaries rather than concrete implementation details. dotnet_diagnostic.CA1859.severity = none +[samples/**/Sample/*.cs] +# Runnable tutorials intentionally keep their domain types in the global +# namespace so each example can be read without namespace ceremony. +dotnet_diagnostic.CA1050.severity = none + [samples/middleware-ordering-changes-behavior/Sample/Program.cs] # This focused middleware-ordering example keeps logging calls inline so the # execution sequence remains visible without unrelated source-generated code. @@ -309,6 +314,10 @@ generated_code = true [**/bin/**/*.cs] generated_code = true -[samples/**/Tests/**/*.cs] +[samples/**/Tests/*.cs] # Sample tests use scenario-style underscores just like top-level test projects. dotnet_diagnostic.CA1707.severity = none + +[samples/**/Tests/**/*.cs] +# Apply the same test-name convention to tests organized in subdirectories. +dotnet_diagnostic.CA1707.severity = none diff --git a/samples/Directory.Build.props b/samples/Directory.Build.props index ddc1000..da53003 100644 --- a/samples/Directory.Build.props +++ b/samples/Directory.Build.props @@ -3,7 +3,14 @@ net10.0 enable enable + 14.0 + true + latest-recommended + true true + true + true + $(NoWarn);CS1591 true diff --git a/tools/publish-x.cs b/tools/publish-x.cs index 3c90d51..f6b1a56 100644 --- a/tools/publish-x.cs +++ b/tools/publish-x.cs @@ -631,6 +631,8 @@ public async Task PublishAsync( sealed class XApiClient(HttpClient httpClient, XCredentials credentials) : IXClient { + private static readonly TimeSpan MaximumRetryDelay = TimeSpan.FromSeconds(30); + private static readonly Uri ApiRoot = new("https://api.x.com/2/"); public async Task> GetRecentPostsAsync(CancellationToken cancellationToken) @@ -777,12 +779,29 @@ private async Task SendWithRetryAsync( throw new InvalidOperationException("X request exhausted its retry limit."); } - private static TimeSpan GetRetryDelay(HttpResponseMessage response, int attempt) + internal static TimeSpan GetRetryDelay( + HttpResponseMessage response, + int attempt, + DateTimeOffset? currentTime = null) { - TimeSpan? retryAfter = response.Headers.RetryAfter?.Delta; - return retryAfter is not null && retryAfter <= TimeSpan.FromSeconds(30) - ? retryAfter.Value - : TimeSpan.FromSeconds(attempt); + RetryConditionHeaderValue? retryCondition = response.Headers.RetryAfter; + TimeSpan? retryAfter = retryCondition?.Delta; + + if (retryAfter is null && retryCondition?.Date is DateTimeOffset retryDate) + { + retryAfter = retryDate - (currentTime ?? DateTimeOffset.UtcNow); + } + + if (retryAfter is null) + { + return TimeSpan.FromSeconds(attempt); + } + + return retryAfter.Value <= TimeSpan.Zero + ? TimeSpan.Zero + : retryAfter.Value >= MaximumRetryDelay + ? MaximumRetryDelay + : retryAfter.Value; } private static Uri BuildUri(Uri uri, IReadOnlyDictionary query) @@ -943,6 +962,7 @@ public static async Task RunAsync() TestInvalidMetadata(); TestComposition(); TestResponseClassification(); + TestRetryDelays(); await TestApiResponsesAsync(); await TestReceiptsAndReconciliationAsync(); await TestAmbiguousDeliveryAsync(); @@ -1046,6 +1066,34 @@ private static void TestResponseClassification() Assert(XApiClient.Classify(HttpStatusCode.BadGateway) == XResponseDisposition.Retryable, "5xx classification failed."); } + private static void TestRetryDelays() + { + DateTimeOffset currentTime = new(2026, 9, 20, 12, 0, 0, TimeSpan.Zero); + + using var deltaResponse = Response(HttpStatusCode.TooManyRequests, "{}"); + deltaResponse.Headers.RetryAfter = new RetryConditionHeaderValue(TimeSpan.FromSeconds(45)); + Assert( + XApiClient.GetRetryDelay(deltaResponse, attempt: 1, currentTime) == TimeSpan.FromSeconds(30), + "long Retry-After deltas should be clamped to the maximum delay."); + + using var dateResponse = Response(HttpStatusCode.TooManyRequests, "{}"); + dateResponse.Headers.RetryAfter = new RetryConditionHeaderValue(currentTime.AddSeconds(20)); + Assert( + XApiClient.GetRetryDelay(dateResponse, attempt: 1, currentTime) == TimeSpan.FromSeconds(20), + "Retry-After dates should delay until the requested time."); + + using var expiredDateResponse = Response(HttpStatusCode.TooManyRequests, "{}"); + expiredDateResponse.Headers.RetryAfter = new RetryConditionHeaderValue(currentTime.AddSeconds(-1)); + Assert( + XApiClient.GetRetryDelay(expiredDateResponse, attempt: 1, currentTime) == TimeSpan.Zero, + "expired Retry-After dates should allow an immediate retry."); + + using var fallbackResponse = Response(HttpStatusCode.BadGateway, "{}"); + Assert( + XApiClient.GetRetryDelay(fallbackResponse, attempt: 2, currentTime) == TimeSpan.FromSeconds(2), + "responses without Retry-After should use the attempt-based fallback."); + } + private static async Task TestApiResponsesAsync() { XCredentials credentials = new("key", "key-secret", "token", "token-secret", "1234");