Browse Source

Added Ids to rate limit, improved bucket logic, added channel bucks and buckettargets, and exposed bucket data.

tags/1.0-rc
RogueException 9 years ago
parent
commit
c9171619d9
12 changed files with 183 additions and 61 deletions
  1. +10
    -2
      src/Discord.Net/DiscordClient.cs
  2. +3
    -5
      src/Discord.Net/DiscordSocketClient.cs
  3. +0
    -16
      src/Discord.Net/Net/Queue/BucketDefinition.cs
  4. +30
    -0
      src/Discord.Net/Net/Queue/Definitions/BucketDefinition.cs
  5. +2
    -1
      src/Discord.Net/Net/Queue/Definitions/BucketGroup.cs
  6. +9
    -0
      src/Discord.Net/Net/Queue/Definitions/BucketTarget.cs
  7. +7
    -0
      src/Discord.Net/Net/Queue/Definitions/ChannelBucket.cs
  8. +0
    -0
      src/Discord.Net/Net/Queue/Definitions/GlobalBucket.cs
  9. +0
    -0
      src/Discord.Net/Net/Queue/Definitions/GuildBucket.cs
  10. +72
    -24
      src/Discord.Net/Net/Queue/RequestQueue.cs
  11. +49
    -13
      src/Discord.Net/Net/Queue/RequestQueueBucket.cs
  12. +1
    -0
      src/Discord.Net/Net/RateLimitException.cs

+ 10
- 2
src/Discord.Net/DiscordClient.cs View File

@@ -18,7 +18,7 @@ namespace Discord
public event Func<LogMessage, Task> Log; public event Func<LogMessage, Task> Log;
public event Func<Task> LoggedIn, LoggedOut; public event Func<Task> LoggedIn, LoggedOut;


internal readonly Logger _discordLogger, _restLogger;
internal readonly Logger _discordLogger, _restLogger, _queueLogger;
internal readonly SemaphoreSlim _connectionLock; internal readonly SemaphoreSlim _connectionLock;
internal readonly LogManager _log; internal readonly LogManager _log;
internal readonly RequestQueue _requestQueue; internal readonly RequestQueue _requestQueue;
@@ -38,12 +38,20 @@ namespace Discord
_log.Message += async msg => await Log.RaiseAsync(msg).ConfigureAwait(false); _log.Message += async msg => await Log.RaiseAsync(msg).ConfigureAwait(false);
_discordLogger = _log.CreateLogger("Discord"); _discordLogger = _log.CreateLogger("Discord");
_restLogger = _log.CreateLogger("Rest"); _restLogger = _log.CreateLogger("Rest");
_queueLogger = _log.CreateLogger("Queue");


_connectionLock = new SemaphoreSlim(1, 1); _connectionLock = new SemaphoreSlim(1, 1);

_requestQueue = new RequestQueue(); _requestQueue = new RequestQueue();
_requestQueue.RateLimitTriggered += async (id, bucket, millis) =>
{
await _queueLogger.WarningAsync($"Rate limit triggered (id = \"{id ?? "null"}\")").ConfigureAwait(false);
if (bucket == null && id != null)
await _queueLogger.WarningAsync($"Unknown rate limit bucket \"{id ?? "null"}\"").ConfigureAwait(false);
};
ApiClient = new API.DiscordApiClient(config.RestClientProvider, (config as DiscordSocketConfig)?.WebSocketProvider, requestQueue: _requestQueue); ApiClient = new API.DiscordApiClient(config.RestClientProvider, (config as DiscordSocketConfig)?.WebSocketProvider, requestQueue: _requestQueue);
ApiClient.SentRequest += async (method, endpoint, millis) => await _log.VerboseAsync("Rest", $"{method} {endpoint}: {millis} ms").ConfigureAwait(false);
ApiClient.SentRequest += async (method, endpoint, millis) => await _restLogger.VerboseAsync($"{method} {endpoint}: {millis} ms").ConfigureAwait(false);
} }


/// <inheritdoc /> /// <inheritdoc />


+ 3
- 5
src/Discord.Net/DiscordSocketClient.cs View File

@@ -21,8 +21,7 @@ namespace Discord
//TODO: Add resume logic //TODO: Add resume logic
public class DiscordSocketClient : DiscordClient, IDiscordClient public class DiscordSocketClient : DiscordClient, IDiscordClient
{ {
public event Func<Task> Connected, Disconnected;
public event Func<Task> Ready;
public event Func<Task> Connected, Disconnected, Ready;
//public event Func<Channel> VoiceConnected, VoiceDisconnected; //public event Func<Channel> VoiceConnected, VoiceDisconnected;
public event Func<IChannel, Task> ChannelCreated, ChannelDestroyed; public event Func<IChannel, Task> ChannelCreated, ChannelDestroyed;
public event Func<IChannel, IChannel, Task> ChannelUpdated; public event Func<IChannel, IChannel, Task> ChannelUpdated;
@@ -174,6 +173,7 @@ namespace Discord
_connectTask = new TaskCompletionSource<bool>(); _connectTask = new TaskCompletionSource<bool>();
_cancelToken = new CancellationTokenSource(); _cancelToken = new CancellationTokenSource();
await ApiClient.ConnectAsync().ConfigureAwait(false); await ApiClient.ConnectAsync().ConfigureAwait(false);
await Connected.RaiseAsync().ConfigureAwait(false);


await _connectTask.Task.ConfigureAwait(false); await _connectTask.Task.ConfigureAwait(false);
@@ -185,8 +185,6 @@ namespace Discord
await DisconnectInternalAsync().ConfigureAwait(false); await DisconnectInternalAsync().ConfigureAwait(false);
throw; throw;
} }

await Connected.RaiseAsync().ConfigureAwait(false);
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task DisconnectAsync() public async Task DisconnectAsync()
@@ -1139,6 +1137,7 @@ namespace Discord


private async Task RunHeartbeatAsync(int intervalMillis, CancellationToken cancelToken) private async Task RunHeartbeatAsync(int intervalMillis, CancellationToken cancelToken)
{ {
//Clean this up when Discord's session patch is live
try try
{ {
while (!cancelToken.IsCancellationRequested) while (!cancelToken.IsCancellationRequested)
@@ -1161,7 +1160,6 @@ namespace Discord
} }
catch (OperationCanceledException) { } catch (OperationCanceledException) { }
} }

private async Task WaitForGuildsAsync(CancellationToken cancelToken) private async Task WaitForGuildsAsync(CancellationToken cancelToken)
{ {
while ((_unavailableGuilds != 0) && (Environment.TickCount - _lastGuildAvailableTime < 2000)) while ((_unavailableGuilds != 0) && (Environment.TickCount - _lastGuildAvailableTime < 2000))


+ 0
- 16
src/Discord.Net/Net/Queue/BucketDefinition.cs View File

@@ -1,16 +0,0 @@
namespace Discord.Net.Queue
{
internal struct BucketDefinition
{
public int WindowCount { get; }
public int WindowSeconds { get; }
public GlobalBucket? Parent { get; }

public BucketDefinition(int windowCount, int windowSeconds, GlobalBucket? parent = null)
{
WindowCount = windowCount;
WindowSeconds = windowSeconds;
Parent = parent;
}
}
}

+ 30
- 0
src/Discord.Net/Net/Queue/Definitions/BucketDefinition.cs View File

@@ -0,0 +1,30 @@
namespace Discord.Net.Queue
{
public sealed class Bucket
{
/// <summary> Gets the unique identifier for this bucket. </summary>
public string Id { get; }
/// <summary> Gets the name of this bucket. </summary>
public string Name { get; }
/// <summary> Gets the amount of requests that may be sent per window. </summary>
public int WindowCount { get; }
/// <summary> Gets the length of this bucket's window, in seconds. </summary>
public int WindowSeconds { get; }
/// <summary> Gets the type of account this bucket affects. </summary>
public BucketTarget Target { get; }
/// <summary> Gets this bucket's parent. </summary>
public GlobalBucket? Parent { get; }

internal Bucket(string id, int windowCount, int windowSeconds, BucketTarget target, GlobalBucket? parent = null)
: this(id, id, windowCount, windowSeconds, target, parent) { }
internal Bucket(string id, string name, int windowCount, int windowSeconds, BucketTarget target, GlobalBucket? parent = null)
{
Id = id;
Name = name;
WindowCount = windowCount;
WindowSeconds = windowSeconds;
Target = target;
Parent = parent;
}
}
}

src/Discord.Net/Net/Queue/BucketGroup.cs → src/Discord.Net/Net/Queue/Definitions/BucketGroup.cs View File

@@ -3,6 +3,7 @@
public enum BucketGroup public enum BucketGroup
{ {
Global, Global,
Guild
Guild,
Channel
} }
} }

+ 9
- 0
src/Discord.Net/Net/Queue/Definitions/BucketTarget.cs View File

@@ -0,0 +1,9 @@
namespace Discord.Net.Queue
{
public enum BucketTarget
{
Client,
Bot,
Both
}
}

+ 7
- 0
src/Discord.Net/Net/Queue/Definitions/ChannelBucket.cs View File

@@ -0,0 +1,7 @@
namespace Discord.Net.Queue
{
public enum ChannelBucket
{
SendEditMessage,
}
}

src/Discord.Net/Net/Queue/GlobalBucket.cs → src/Discord.Net/Net/Queue/Definitions/GlobalBucket.cs View File


src/Discord.Net/Net/Queue/GuildBucket.cs → src/Discord.Net/Net/Queue/Definitions/GuildBucket.cs View File


+ 72
- 24
src/Discord.Net/Net/Queue/RequestQueue.cs View File

@@ -10,52 +10,83 @@ namespace Discord.Net.Queue
{ {
public class RequestQueue public class RequestQueue
{ {
private readonly static ImmutableDictionary<GlobalBucket, BucketDefinition> _globalLimits;
private readonly static ImmutableDictionary<GuildBucket, BucketDefinition> _guildLimits;
public event Func<string, Bucket, int, Task> RateLimitTriggered;

private readonly static ImmutableDictionary<GlobalBucket, Bucket> _globalLimits;
private readonly static ImmutableDictionary<GuildBucket, Bucket> _guildLimits;
private readonly static ImmutableDictionary<ChannelBucket, Bucket> _channelLimits;
private readonly SemaphoreSlim _lock; private readonly SemaphoreSlim _lock;
private readonly RequestQueueBucket[] _globalBuckets; private readonly RequestQueueBucket[] _globalBuckets;
private readonly ConcurrentDictionary<ulong, RequestQueueBucket>[] _guildBuckets; private readonly ConcurrentDictionary<ulong, RequestQueueBucket>[] _guildBuckets;
private readonly ConcurrentDictionary<ulong, RequestQueueBucket>[] _channelBuckets;
private CancellationTokenSource _clearToken; private CancellationTokenSource _clearToken;
private CancellationToken _parentToken; private CancellationToken _parentToken;
private CancellationToken _cancelToken; private CancellationToken _cancelToken;


static RequestQueue() static RequestQueue()
{ {
_globalLimits = new Dictionary<GlobalBucket, BucketDefinition>
_globalLimits = new Dictionary<GlobalBucket, Bucket>
{ {
//REST //REST
[GlobalBucket.GeneralRest] = new BucketDefinition(0, 0), //No Limit
[GlobalBucket.GeneralRest] = new Bucket(null, "rest", 0, 0, BucketTarget.Both), //No Limit
//[GlobalBucket.Login] = new BucketDefinition(1, 1), //[GlobalBucket.Login] = new BucketDefinition(1, 1),
[GlobalBucket.DirectMessage] = new BucketDefinition(5, 5),
[GlobalBucket.SendEditMessage] = new BucketDefinition(50, 10),
[GlobalBucket.DirectMessage] = new Bucket("bot:msg:dm", 5, 5, BucketTarget.Bot),
[GlobalBucket.SendEditMessage] = new Bucket("bot:msg:global", 50, 10, BucketTarget.Bot),


//Gateway //Gateway
[GlobalBucket.GeneralGateway] = new BucketDefinition(120, 60),
[GlobalBucket.UpdateStatus] = new BucketDefinition(5, 1, GlobalBucket.GeneralGateway)
[GlobalBucket.GeneralGateway] = new Bucket(null, "gateway", 120, 60, BucketTarget.Both),
[GlobalBucket.UpdateStatus] = new Bucket(null, "status", 5, 1, BucketTarget.Both, GlobalBucket.GeneralGateway)
}.ToImmutableDictionary();

_guildLimits = new Dictionary<GuildBucket, Bucket>
{
//REST
[GuildBucket.SendEditMessage] = new Bucket("bot:msg:server", 5, 5, BucketTarget.Bot, GlobalBucket.SendEditMessage),
[GuildBucket.DeleteMessage] = new Bucket("dmsg", 5, 1, BucketTarget.Bot),
[GuildBucket.DeleteMessages] = new Bucket("bdmsg", 1, 1, BucketTarget.Bot),
[GuildBucket.ModifyMember] = new Bucket("guild_member", 10, 10, BucketTarget.Bot),
[GuildBucket.Nickname] = new Bucket("guild_member_nick", 1, 1, BucketTarget.Bot)
}.ToImmutableDictionary(); }.ToImmutableDictionary();


_guildLimits = new Dictionary<GuildBucket, BucketDefinition>
//Client-Only
_channelLimits = new Dictionary<ChannelBucket, Bucket>
{ {
//REST //REST
[GuildBucket.SendEditMessage] = new BucketDefinition(5, 5, GlobalBucket.SendEditMessage),
[GuildBucket.DeleteMessage] = new BucketDefinition(5, 1),
[GuildBucket.DeleteMessages] = new BucketDefinition(1, 1),
[GuildBucket.ModifyMember] = new BucketDefinition(10, 10),
[GuildBucket.Nickname] = new BucketDefinition(1, 1)
[ChannelBucket.SendEditMessage] = new Bucket("msg", 10, 10, BucketTarget.Client, GlobalBucket.SendEditMessage),
}.ToImmutableDictionary(); }.ToImmutableDictionary();
} }


public static Bucket GetBucketInfo(GlobalBucket bucket) => _globalLimits[bucket];
public static Bucket GetBucketInfo(GuildBucket bucket) => _guildLimits[bucket];
public static Bucket GetBucketInfo(ChannelBucket bucket) => _channelLimits[bucket];

public RequestQueue() public RequestQueue()
{ {
_lock = new SemaphoreSlim(1, 1); _lock = new SemaphoreSlim(1, 1);


_globalBuckets = new RequestQueueBucket[_globalLimits.Count]; _globalBuckets = new RequestQueueBucket[_globalLimits.Count];
foreach (var pair in _globalLimits) foreach (var pair in _globalLimits)
_globalBuckets[(int)pair.Key] = CreateBucket(pair.Value);
{
//var target = _globalLimits[pair.Key].Target;
//if (target == BucketTarget.Both || (target == BucketTarget.Bot && isBot) || (target == BucketTarget.Client && !isBot))
_globalBuckets[(int)pair.Key] = CreateBucket(pair.Value);
}


_guildBuckets = new ConcurrentDictionary<ulong, RequestQueueBucket>[_guildLimits.Count]; _guildBuckets = new ConcurrentDictionary<ulong, RequestQueueBucket>[_guildLimits.Count];
for (int i = 0; i < _guildLimits.Count; i++) for (int i = 0; i < _guildLimits.Count; i++)
_guildBuckets[i] = new ConcurrentDictionary<ulong, RequestQueueBucket>();
{
//var target = _guildLimits[(GuildBucket)i].Target;
//if (target == BucketTarget.Both || (target == BucketTarget.Bot && isBot) || (target == BucketTarget.Client && !isBot))
_guildBuckets[i] = new ConcurrentDictionary<ulong, RequestQueueBucket>();
}
_channelBuckets = new ConcurrentDictionary<ulong, RequestQueueBucket>[_channelLimits.Count];
for (int i = 0; i < _channelLimits.Count; i++)
{
//var target = _channelLimits[(GuildBucket)i].Target;
//if (target == BucketTarget.Both || (target == BucketTarget.Bot && isBot) || (target == BucketTarget.Client && !isBot))
_channelBuckets[i] = new ConcurrentDictionary<ulong, RequestQueueBucket>();
}


_clearToken = new CancellationTokenSource(); _clearToken = new CancellationTokenSource();
_cancelToken = CancellationToken.None; _cancelToken = CancellationToken.None;
@@ -72,23 +103,23 @@ namespace Discord.Net.Queue
finally { _lock.Release(); } finally { _lock.Release(); }
} }


internal async Task<Stream> SendAsync(RestRequest request, BucketGroup group, int bucketId, ulong guildId)
internal async Task<Stream> SendAsync(RestRequest request, BucketGroup group, int bucketId, ulong objId)
{ {
request.CancelToken = _cancelToken; request.CancelToken = _cancelToken;
var bucket = GetBucket(group, bucketId, guildId);
var bucket = GetBucket(group, bucketId, objId);
return await bucket.SendAsync(request).ConfigureAwait(false); return await bucket.SendAsync(request).ConfigureAwait(false);
} }
internal async Task<Stream> SendAsync(WebSocketRequest request, BucketGroup group, int bucketId, ulong guildId)
internal async Task<Stream> SendAsync(WebSocketRequest request, BucketGroup group, int bucketId, ulong objId)
{ {
request.CancelToken = _cancelToken; request.CancelToken = _cancelToken;
var bucket = GetBucket(group, bucketId, guildId);
var bucket = GetBucket(group, bucketId, objId);
return await bucket.SendAsync(request).ConfigureAwait(false); return await bucket.SendAsync(request).ConfigureAwait(false);
} }
private RequestQueueBucket CreateBucket(BucketDefinition def)
private RequestQueueBucket CreateBucket(Bucket def)
{ {
var parent = def.Parent != null ? GetGlobalBucket(def.Parent.Value) : null; var parent = def.Parent != null ? GetGlobalBucket(def.Parent.Value) : null;
return new RequestQueueBucket(def.WindowCount, def.WindowSeconds * 1000, parent);
return new RequestQueueBucket(this, def, parent);
} }


public void DestroyGuildBucket(GuildBucket type, ulong guildId) public void DestroyGuildBucket(GuildBucket type, ulong guildId)
@@ -97,15 +128,23 @@ namespace Discord.Net.Queue
RequestQueueBucket bucket; RequestQueueBucket bucket;
_guildBuckets[(int)type].TryRemove(guildId, out bucket); _guildBuckets[(int)type].TryRemove(guildId, out bucket);
} }
public void DestroyChannelBucket(ChannelBucket type, ulong channelId)
{
//Assume this object is locked
RequestQueueBucket bucket;
_channelBuckets[(int)type].TryRemove(channelId, out bucket);
}


private RequestQueueBucket GetBucket(BucketGroup group, int bucketId, ulong guildId)
private RequestQueueBucket GetBucket(BucketGroup group, int bucketId, ulong objId)
{ {
switch (group) switch (group)
{ {
case BucketGroup.Global: case BucketGroup.Global:
return GetGlobalBucket((GlobalBucket)bucketId); return GetGlobalBucket((GlobalBucket)bucketId);
case BucketGroup.Guild: case BucketGroup.Guild:
return GetGuildBucket((GuildBucket)bucketId, guildId);
return GetGuildBucket((GuildBucket)bucketId, objId);
case BucketGroup.Channel:
return GetChannelBucket((ChannelBucket)bucketId, objId);
default: default:
throw new ArgumentException($"Unknown bucket group: {group}", nameof(group)); throw new ArgumentException($"Unknown bucket group: {group}", nameof(group));
} }
@@ -118,6 +157,10 @@ namespace Discord.Net.Queue
{ {
return _guildBuckets[(int)type].GetOrAdd(guildId, _ => CreateBucket(_guildLimits[type])); return _guildBuckets[(int)type].GetOrAdd(guildId, _ => CreateBucket(_guildLimits[type]));
} }
private RequestQueueBucket GetChannelBucket(ChannelBucket type, ulong channelId)
{
return _channelBuckets[(int)type].GetOrAdd(channelId, _ => CreateBucket(_channelLimits[type]));
}


public async Task ClearAsync() public async Task ClearAsync()
{ {
@@ -133,5 +176,10 @@ namespace Discord.Net.Queue
} }
finally { _lock.Release(); } finally { _lock.Release(); }
} }

internal async Task RaiseRateLimitTriggered(string id, Bucket bucket, int millis)
{
await RateLimitTriggered.Invoke(id, bucket, millis).ConfigureAwait(false);
}
} }
} }

+ 49
- 13
src/Discord.Net/Net/Queue/RequestQueueBucket.cs View File

@@ -9,27 +9,51 @@ namespace Discord.Net.Queue
{ {
internal class RequestQueueBucket internal class RequestQueueBucket
{ {
private readonly int _windowMilliseconds;
private readonly RequestQueue _queue;
private readonly SemaphoreSlim _semaphore; private readonly SemaphoreSlim _semaphore;
private readonly object _pauseLock; private readonly object _pauseLock;
private int _pauseEndTick; private int _pauseEndTick;
private TaskCompletionSource<byte> _resumeNotifier; private TaskCompletionSource<byte> _resumeNotifier;


public Bucket Definition { get; }
public RequestQueueBucket Parent { get; } public RequestQueueBucket Parent { get; }
public Task _resetTask { get; } public Task _resetTask { get; }


public RequestQueueBucket(int windowCount, int windowMilliseconds, RequestQueueBucket parent = null)
public RequestQueueBucket(RequestQueue queue, Bucket definition, RequestQueueBucket parent = null)
{ {
if (windowCount != 0)
_semaphore = new SemaphoreSlim(windowCount, windowCount);
_queue = queue;
Definition = definition;
if (definition.WindowCount != 0)
_semaphore = new SemaphoreSlim(definition.WindowCount, definition.WindowCount);
Parent = parent;

_pauseLock = new object(); _pauseLock = new object();
_resumeNotifier = new TaskCompletionSource<byte>(); _resumeNotifier = new TaskCompletionSource<byte>();
_resumeNotifier.SetResult(0); _resumeNotifier.SetResult(0);
_windowMilliseconds = windowMilliseconds;
Parent = parent;
} }


public async Task<Stream> SendAsync(IQueuedRequest request) public async Task<Stream> SendAsync(IQueuedRequest request)
{
while (true)
{
try
{
return await SendAsyncInternal(request).ConfigureAwait(false);
}
catch (HttpRateLimitException ex)
{
//When a 429 occurs, we drop all our locks, including the ones we wanted.
//This is generally safe though since 429s actually occuring should be very rare.
RequestQueueBucket bucket;
bool success = FindBucket(ex.BucketId, out bucket);

await _queue.RaiseRateLimitTriggered(ex.BucketId, success ? bucket.Definition : (Bucket)null, ex.RetryAfterMilliseconds).ConfigureAwait(false);

bucket.Pause(ex.RetryAfterMilliseconds);
}
}
}
private async Task<Stream> SendAsyncInternal(IQueuedRequest request)
{ {
var endTick = request.TimeoutTick; var endTick = request.TimeoutTick;


@@ -64,7 +88,7 @@ namespace Discord.Net.Queue
{ {
//If there's a parent bucket, pass this request to them //If there's a parent bucket, pass this request to them
if (Parent != null) if (Parent != null)
return await Parent.SendAsync(request).ConfigureAwait(false);
return await Parent.SendAsyncInternal(request).ConfigureAwait(false);


//We have all our semaphores, send the request //We have all our semaphores, send the request
return await request.SendAsync().ConfigureAwait(false); return await request.SendAsync().ConfigureAwait(false);
@@ -73,11 +97,6 @@ namespace Discord.Net.Queue
{ {
continue; continue;
} }
catch (HttpRateLimitException ex)
{
Pause(ex.RetryAfterMilliseconds);
continue;
}
} }
} }
finally finally
@@ -88,6 +107,23 @@ namespace Discord.Net.Queue
} }
} }


private bool FindBucket(string id, out RequestQueueBucket bucket)
{
//Keep going up until we find a bucket with matching id or we're at the topmost bucket
if (Definition.Id == id)
{
bucket = this;
return true;
}
else if (Parent == null)
{
bucket = this;
return false;
}
else
return Parent.FindBucket(id, out bucket);
}

private void Pause(int milliseconds) private void Pause(int milliseconds)
{ {
lock (_pauseLock) lock (_pauseLock)
@@ -120,7 +156,7 @@ namespace Discord.Net.Queue
} }
private async Task QueueExitAsync() private async Task QueueExitAsync()
{ {
await Task.Delay(_windowMilliseconds).ConfigureAwait(false);
await Task.Delay(Definition.WindowSeconds * 1000).ConfigureAwait(false);
_semaphore.Release(); _semaphore.Release();
} }
} }


+ 1
- 0
src/Discord.Net/Net/RateLimitException.cs View File

@@ -4,6 +4,7 @@ namespace Discord.Net
{ {
public class HttpRateLimitException : HttpException public class HttpRateLimitException : HttpException
{ {
public string BucketId { get; }
public int RetryAfterMilliseconds { get; } public int RetryAfterMilliseconds { get; }


public HttpRateLimitException(int retryAfterMilliseconds) public HttpRateLimitException(int retryAfterMilliseconds)


Loading…
Cancel
Save