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");