Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
7 changes: 7 additions & 0 deletions samples/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>14.0</LangVersion>
<EnableNETAnalyzers>true</EnableNETAnalyzers>
<AnalysisLevel>latest-recommended</AnalysisLevel>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<CodeAnalysisTreatWarningsAsErrors>true</CodeAnalysisTreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
</Project>
58 changes: 53 additions & 5 deletions tools/publish-x.cs
Original file line number Diff line number Diff line change
Expand Up @@ -631,6 +631,8 @@ public async Task<PublisherState> 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<IReadOnlyList<XPost>> GetRecentPostsAsync(CancellationToken cancellationToken)
Expand Down Expand Up @@ -777,12 +779,29 @@ private async Task<HttpResponseMessage> 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<string, string> query)
Expand Down Expand Up @@ -943,6 +962,7 @@ public static async Task<int> RunAsync()
TestInvalidMetadata();
TestComposition();
TestResponseClassification();
TestRetryDelays();
await TestApiResponsesAsync();
await TestReceiptsAndReconciliationAsync();
await TestAmbiguousDeliveryAsync();
Expand Down Expand Up @@ -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");
Expand Down
Loading