Skip to content

Commit a8cf6d7

Browse files
LulalabyCopilot
andcommitted
feat(rest): add direct execution fast path for interaction callbacks
Interaction callbacks have a 3-second Discord deadline that makes bucket worker queueing overhead unacceptable. Add ExecuteDirectAsync to RestClient that bypasses the worker queue and sends immediately via SendAndParseAsync, failing on any error without retries. Wire CreateInteractionResponseAsync, CreateInteractionModalResponseAsync, and CreateInteractionIframeResponseAsync to use the new direct path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent f7367fd commit a8cf6d7

2 files changed

Lines changed: 102 additions & 4 deletions

File tree

DisCatSharp/Net/Rest/DiscordApiClient.cs

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,31 @@ internal Task<RestResponse> DoRequestAsync(BaseDiscordClient client, RateLimitBu
291291
return req.WaitForCompletionAsync();
292292
}
293293

294+
/// <summary>
295+
/// Executes a rest request directly, bypassing the bucket worker queue.
296+
/// Used for latency-sensitive endpoints like interaction callbacks.
297+
/// </summary>
298+
/// <param name="client">The client.</param>
299+
/// <param name="bucket">The bucket.</param>
300+
/// <param name="url">The url.</param>
301+
/// <param name="method">The method.</param>
302+
/// <param name="route">The route.</param>
303+
/// <param name="headers">The headers.</param>
304+
/// <param name="payload">The payload.</param>
305+
/// <param name="ratelimitWaitOverride">The ratelimit wait override.</param>
306+
/// <param name="cancellationToken">A token to cancel the request.</param>
307+
internal Task<RestResponse> DoDirectRequestAsync(BaseDiscordClient client, RateLimitBucket bucket, Uri url, RestRequestMethod method, string route, IReadOnlyDictionary<string, string>? headers = null, string? payload = null, double? ratelimitWaitOverride = null, CancellationToken cancellationToken = default)
308+
{
309+
var req = new RestRequest(client, bucket, url, method, route, headers, payload, ratelimitWaitOverride, cancellationToken: cancellationToken);
310+
311+
if (this.Discord is not null)
312+
this.Rest.ExecuteDirectAsync(req).LogTaskFault(this.Discord.Logger, LogLevel.Error, LoggerEvents.RestError, $"Error while executing direct request. Url: {url.AbsoluteUri}");
313+
else
314+
_ = this.Rest.ExecuteDirectAsync(req);
315+
316+
return req.WaitForCompletionAsync();
317+
}
318+
294319
/// <summary>
295320
/// Builds a CSV file for target users from the provided user ids.
296321
/// </summary>
@@ -518,6 +543,47 @@ private Task<RestResponse> DoMultipartAsync(
518543
return req.WaitForCompletionAsync();
519544
}
520545

546+
/// <summary>
547+
/// Executes a multipart request directly, bypassing the bucket worker queue.
548+
/// Used for latency-sensitive endpoints like interaction callbacks with file uploads.
549+
/// </summary>
550+
/// <param name="client">The client.</param>
551+
/// <param name="bucket">The bucket.</param>
552+
/// <param name="url">The url.</param>
553+
/// <param name="method">The method.</param>
554+
/// <param name="route">The route.</param>
555+
/// <param name="headers">The headers.</param>
556+
/// <param name="values">The values.</param>
557+
/// <param name="files">The files.</param>
558+
/// <param name="ratelimitWaitOverride">The ratelimit wait override.</param>
559+
/// <param name="overwriteFileIdStart">The file id start to overwrite.</param>
560+
/// <param name="fileFieldNameFactory">The factory function to generate file field names.</param>
561+
/// <param name="cancellationToken">A token to cancel the request.</param>
562+
private Task<RestResponse> DoDirectMultipartAsync(
563+
BaseDiscordClient client,
564+
RateLimitBucket bucket,
565+
Uri url,
566+
RestRequestMethod method,
567+
string route,
568+
IReadOnlyDictionary<string, string>? headers = null,
569+
IReadOnlyDictionary<string, string>? values = null,
570+
IEnumerable<DiscordMessageFile>? files = null,
571+
double? ratelimitWaitOverride = null,
572+
int? overwriteFileIdStart = null,
573+
Func<int, string>? fileFieldNameFactory = null,
574+
CancellationToken cancellationToken = default
575+
)
576+
{
577+
var req = new MultipartWebRequest(client, bucket, url, method, route, headers, values, files, ratelimitWaitOverride, overwriteFileIdStart, fileFieldNameFactory, cancellationToken);
578+
579+
if (this.Discord is not null)
580+
this.Rest.ExecuteDirectAsync(req).LogTaskFault(this.Discord.Logger, LogLevel.Error, LoggerEvents.RestError, "Error while executing direct request");
581+
else
582+
_ = this.Rest.ExecuteDirectAsync(req);
583+
584+
return req.WaitForCompletionAsync();
585+
}
586+
521587
/// <summary>
522588
/// Executes a multipart request.
523589
/// </summary>
@@ -8104,14 +8170,14 @@ internal async Task<DiscordInteractionCallbackResponse> CreateInteractionRespons
81048170
var url = Utilities.GetApiUriBuilderFor(path, this.Discord.Configuration).AddParameter("wait", "false").AddParameter("with_response", "true").Build();
81058171
if (builder is not null && builder.Files is not null && values.Count is not 0)
81068172
{
8107-
response = await this.DoMultipartAsync(this.Discord, bucket, url, RestRequestMethod.POST, route, values: values, files: builder.Files, cancellationToken: cancellationToken).ConfigureAwait(false);
8173+
response = await this.DoDirectMultipartAsync(this.Discord, bucket, url, RestRequestMethod.POST, route, values: values, files: builder.Files, cancellationToken: cancellationToken).ConfigureAwait(false);
81088174

81098175
if (builder.Files is not null)
81108176
foreach (var file in builder.Files.Where(x => x.ResetPositionTo.HasValue))
81118177
file.Stream.Position = file.ResetPositionTo!.Value;
81128178
}
81138179
else
8114-
response = await this.DoRequestAsync(this.Discord, bucket, url, RestRequestMethod.POST, route, payload: DiscordJson.SerializeObject(pld), cancellationToken: cancellationToken).ConfigureAwait(false);
8180+
response = await this.DoDirectRequestAsync(this.Discord, bucket, url, RestRequestMethod.POST, route, payload: DiscordJson.SerializeObject(pld), cancellationToken: cancellationToken).ConfigureAwait(false);
81158181

81168182
return response.ResponseCode is not HttpStatusCode.NoContent && !string.IsNullOrEmpty(response.Response)
81178183
? DiscordJson.DeserializeObject<DiscordInteractionCallbackResponse>(response.Response, this.Discord)
@@ -8155,7 +8221,7 @@ internal async Task CreateInteractionModalResponseAsync(ulong interactionId, str
81558221
}, out var path);
81568222

81578223
var url = Utilities.GetApiUriBuilderFor(path, this.Discord.Configuration).AddParameter("wait", "true").Build();
8158-
await this.DoRequestAsync(this.Discord, bucket, url, RestRequestMethod.POST, route, payload: DiscordJson.SerializeObject(pld), cancellationToken: cancellationToken).ConfigureAwait(false);
8224+
await this.DoDirectRequestAsync(this.Discord, bucket, url, RestRequestMethod.POST, route, payload: DiscordJson.SerializeObject(pld), cancellationToken: cancellationToken).ConfigureAwait(false);
81598225
}
81608226

81618227
/// <summary>
@@ -8193,7 +8259,7 @@ internal async Task CreateInteractionIframeResponseAsync(ulong interactionId, st
81938259
}, out var path);
81948260

81958261
var url = Utilities.GetApiUriBuilderFor(path, this.Discord.Configuration).AddParameter("wait", "true").Build();
8196-
await this.DoRequestAsync(this.Discord, bucket, url, RestRequestMethod.POST, route, payload: DiscordJson.SerializeObject(pld), cancellationToken: cancellationToken).ConfigureAwait(false);
8262+
await this.DoDirectRequestAsync(this.Discord, bucket, url, RestRequestMethod.POST, route, payload: DiscordJson.SerializeObject(pld), cancellationToken: cancellationToken).ConfigureAwait(false);
81978263
}
81988264

81998265
/// <summary>

DisCatSharp/Net/Rest/RestClient.cs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,38 @@ public async Task ExecuteRequestAsync(BaseRestRequest request)
192192
await request.WaitForCompletionAsync().ConfigureAwait(false);
193193
}
194194

195+
/// <summary>
196+
/// Executes a request directly without going through the bucket worker queue.
197+
/// Used for latency-sensitive endpoints (e.g., interaction callbacks) where
198+
/// the 3-second Discord response deadline makes queueing overhead unacceptable.
199+
/// On any failure (rate limit, transient, server error), the request is failed
200+
/// immediately without retries.
201+
/// </summary>
202+
/// <param name="request">The request to execute directly.</param>
203+
internal async Task ExecuteDirectAsync(BaseRestRequest request)
204+
{
205+
ArgumentNullException.ThrowIfNull(request);
206+
207+
if (this._disposed)
208+
{
209+
request.SetFaulted(new ObjectDisposedException(nameof(RestClient), "Cannot execute request on a disposed RestClient."));
210+
request.CancellationTokenSource.Dispose();
211+
return;
212+
}
213+
214+
var result = await this.SendAndParseAsync(request, isProbe: false, request.CancellationTokenSource.Token).ConfigureAwait(false);
215+
216+
if (result.Error is not null)
217+
{
218+
request.SetFaulted(result.Error);
219+
this.ReportDiagnostics(request, result.Response!, result.Error);
220+
}
221+
else
222+
{
223+
request.SetCompleted(result.Response!);
224+
}
225+
}
226+
195227
/// <summary>
196228
/// Executes the form data request by enqueuing it into the appropriate bucket worker.
197229
/// Form and regular requests share the same queue/worker infrastructure.

0 commit comments

Comments
 (0)