diff --git a/source/src/Slackbot.Net.SlackClients.Http/Exceptions/SlackRateLimitedException.cs b/source/src/Slackbot.Net.SlackClients.Http/Exceptions/SlackRateLimitedException.cs new file mode 100644 index 0000000..4192b8f --- /dev/null +++ b/source/src/Slackbot.Net.SlackClients.Http/Exceptions/SlackRateLimitedException.cs @@ -0,0 +1,17 @@ +using System.Net; + +namespace Slackbot.Net.SlackClients.Http.Exceptions; + +public class SlackRateLimitedException : HttpRequestException +{ + public SlackRateLimitedException(TimeSpan retryAfter, string responseContent) + : base($"Slack rate limited the request. Retry after {retryAfter.TotalSeconds} seconds.", null, HttpStatusCode.TooManyRequests) + { + RetryAfter = retryAfter; + ResponseContent = responseContent; + } + + public TimeSpan RetryAfter { get; } + + public string ResponseContent { get; } +} diff --git a/source/src/Slackbot.Net.SlackClients.Http/Extensions/HttpClientExtensions.cs b/source/src/Slackbot.Net.SlackClients.Http/Extensions/HttpClientExtensions.cs index 4166521..51a97a0 100644 --- a/source/src/Slackbot.Net.SlackClients.Http/Extensions/HttpClientExtensions.cs +++ b/source/src/Slackbot.Net.SlackClients.Http/Extensions/HttpClientExtensions.cs @@ -1,3 +1,4 @@ +using System.Net; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; @@ -40,6 +41,7 @@ public static async Task PostJson(this HttpClient httpClient, object paylo logger?.Invoke(responseContent); } + ThrowIfRateLimited(response, responseContent); response.EnsureSuccessStatusCode(); var resObj = JsonSerializer.Deserialize(responseContent, JsonSerializerSettings); @@ -75,6 +77,7 @@ public static async Task PostParametersAsForm(this HttpClient httpClient, logger?.Invoke($"{response.StatusCode} \n {responseContent}"); } + ThrowIfRateLimited(response, responseContent); response.EnsureSuccessStatusCode(); var resObj = JsonSerializer.Deserialize(responseContent, JsonSerializerSettings); @@ -109,6 +112,7 @@ public static async Task PostParametersAsMultiPartFormData(this HttpClient logger?.Invoke($"{response.StatusCode} \n {responseContent}"); } + ThrowIfRateLimited(response, responseContent); response.EnsureSuccessStatusCode(); var resObj = JsonSerializer.Deserialize(responseContent, JsonSerializerSettings); @@ -118,6 +122,17 @@ public static async Task PostParametersAsMultiPartFormData(this HttpClient return resObj; } + + private static readonly TimeSpan DefaultRetryAfter = TimeSpan.FromSeconds(60); + + private static void ThrowIfRateLimited(HttpResponseMessage response, string responseContent) + { + if (response.StatusCode != HttpStatusCode.TooManyRequests) + return; + + var retryAfter = response.Headers.RetryAfter?.Delta ?? DefaultRetryAfter; + throw new SlackRateLimitedException(retryAfter, responseContent); + } } internal class LowerCaseNaming : JsonNamingPolicy diff --git a/source/src/Slackbot.Net.SlackClients.Http/ISlackClient.cs b/source/src/Slackbot.Net.SlackClients.Http/ISlackClient.cs index 452c62d..97baf40 100644 --- a/source/src/Slackbot.Net.SlackClients.Http/ISlackClient.cs +++ b/source/src/Slackbot.Net.SlackClients.Http/ISlackClient.cs @@ -68,6 +68,13 @@ public interface ISlackClient /// https://api.slack.com/methods/users.list Task UsersList(); + /// + /// Scopes required: `users:read` + /// Returns one page of users. Pass the previous page's `response_metadata.next_cursor` to fetch the next page. + /// + /// https://api.slack.com/methods/users.list + Task UsersList(string cursor, int limit = 200); + /// /// Scopes required: channels:read /// Only requests `public_channel` types of conversations diff --git a/source/src/Slackbot.Net.SlackClients.Http/Models/Responses/UsersList/UsersListResponse.cs b/source/src/Slackbot.Net.SlackClients.Http/Models/Responses/UsersList/UsersListResponse.cs index c06b5f7..1ae1f95 100644 --- a/source/src/Slackbot.Net.SlackClients.Http/Models/Responses/UsersList/UsersListResponse.cs +++ b/source/src/Slackbot.Net.SlackClients.Http/Models/Responses/UsersList/UsersListResponse.cs @@ -1,8 +1,12 @@ +using Slackbot.Net.SlackClients.Http.Models.Responses.ConversationsList; + namespace Slackbot.Net.SlackClients.Http.Models.Responses.UsersList; public class UsersListResponse : Response { public User[] Members { get; set; } + + public ResponseMetadata Response_Metadata { get; set; } } public class User diff --git a/source/src/Slackbot.Net.SlackClients.Http/SlackClient.cs b/source/src/Slackbot.Net.SlackClients.Http/SlackClient.cs index 0ef41a5..c63a13b 100644 --- a/source/src/Slackbot.Net.SlackClients.Http/SlackClient.cs +++ b/source/src/Slackbot.Net.SlackClients.Http/SlackClient.cs @@ -98,6 +98,20 @@ public async Task UsersList() return await _client.PostParametersAsForm(null, "users.list", s => _logger.LogTrace(s)); } + /// + public async Task UsersList(string cursor, int limit = 200) + { + var parameters = new List> + { + new KeyValuePair("limit", limit.ToString()), + }; + if (!string.IsNullOrEmpty(cursor)) + { + parameters.Add(new KeyValuePair("cursor", cursor)); + } + return await _client.PostParametersAsForm(parameters, "users.list", s => _logger.LogTrace(s)); + } + /// public async Task ConversationsListPublicChannels(int? limit = null, string cursor = null) { diff --git a/source/test/Slackbot.Net.SlackClients.Http.Tests/Helpers/StubHttpMessageHandler.cs b/source/test/Slackbot.Net.SlackClients.Http.Tests/Helpers/StubHttpMessageHandler.cs index 98fb08d..48a06bc 100644 --- a/source/test/Slackbot.Net.SlackClients.Http.Tests/Helpers/StubHttpMessageHandler.cs +++ b/source/test/Slackbot.Net.SlackClients.Http.Tests/Helpers/StubHttpMessageHandler.cs @@ -5,14 +5,19 @@ namespace Slackbot.Net.Tests.Helpers; // Returns a canned JSON body for any request, so the real SlackClient deserialization // path (including its JsonSerializerOptions/naming policy) runs without hitting Slack. -public class StubHttpMessageHandler(string json) : HttpMessageHandler +public class StubHttpMessageHandler(string json, HttpStatusCode statusCode = HttpStatusCode.OK, Action configureResponse = null) : HttpMessageHandler { - protected override Task SendAsync(HttpRequestMessage request, + public string LastRequestContent { get; private set; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + LastRequestContent = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken); + var response = new HttpResponseMessage(statusCode) { Content = new StringContent(json, Encoding.UTF8, "application/json") - }); + }; + configureResponse?.Invoke(response); + return response; } } diff --git a/source/test/Slackbot.Net.SlackClients.Http.Tests/UsersListPagingTests.cs b/source/test/Slackbot.Net.SlackClients.Http.Tests/UsersListPagingTests.cs new file mode 100644 index 0000000..ee615e7 --- /dev/null +++ b/source/test/Slackbot.Net.SlackClients.Http.Tests/UsersListPagingTests.cs @@ -0,0 +1,86 @@ +using System.Net; +using System.Net.Http.Headers; +using Slackbot.Net.SlackClients.Http; +using Slackbot.Net.SlackClients.Http.Exceptions; +using Slackbot.Net.SlackClients.Http.Models.Requests.ChatPostMessage; +using Slackbot.Net.Tests.Helpers; + +namespace Slackbot.Net.Tests; + +public class UsersListPagingTests(ITestOutputHelper helper) +{ + private const string PageJson = """ + { + "ok": true, + "members": [{ "id": "U023BECGF", "name": "bobby" }], + "response_metadata": { "next_cursor": "dXNlcjpVMEc5V0ZYTlo=" } + } + """; + + private ISlackClient ClientFor(StubHttpMessageHandler handler) => + new SlackClient( + new HttpClient(handler) { BaseAddress = new Uri("https://slack.com/api/") }, + new XUnitLogger(helper)); + + [Fact] + public async Task UsersList_SendsLimitAndCursor() + { + var handler = new StubHttpMessageHandler(PageJson); + + await ClientFor(handler).UsersList("dXNlcjpVMEc5V0ZYTlo=", 100); + + Assert.Equal("limit=100&cursor=dXNlcjpVMEc5V0ZYTlo%3D", handler.LastRequestContent); + } + + [Fact] + public async Task UsersList_OmitsCursor_WhenEmpty() + { + var handler = new StubHttpMessageHandler(PageJson); + + await ClientFor(handler).UsersList(null); + + Assert.Equal("limit=200", handler.LastRequestContent); + } + + [Fact] + public async Task UsersList_PopulatesNextCursor() + { + var response = await ClientFor(new StubHttpMessageHandler(PageJson)).UsersList(null); + + Assert.Equal("U023BECGF", Assert.Single(response.Members).Id); + Assert.Equal("dXNlcjpVMEc5V0ZYTlo=", response.Response_Metadata.Next_Cursor); + } + + [Fact] + public async Task UsersList_ThrowsRateLimited_WithRetryAfter() + { + var handler = new StubHttpMessageHandler("""{ "ok": false, "error": "ratelimited" }""", HttpStatusCode.TooManyRequests, + r => r.Headers.RetryAfter = new RetryConditionHeaderValue(TimeSpan.FromSeconds(30))); + + var exception = await Assert.ThrowsAsync(() => ClientFor(handler).UsersList(null)); + + Assert.Equal(TimeSpan.FromSeconds(30), exception.RetryAfter); + Assert.Equal(HttpStatusCode.TooManyRequests, exception.StatusCode); + } + + [Fact] + public async Task RateLimited_FallsBackToSixtySeconds_WithoutRetryAfter() + { + var handler = new StubHttpMessageHandler("""{ "ok": false, "error": "ratelimited" }""", HttpStatusCode.TooManyRequests); + + var exception = await Assert.ThrowsAsync(() => ClientFor(handler).UsersList()); + + Assert.Equal(TimeSpan.FromSeconds(60), exception.RetryAfter); + } + + [Fact] + public async Task RateLimited_IsCatchableAsHttpRequestException_OnJsonPosts() + { + var handler = new StubHttpMessageHandler("""{ "ok": false, "error": "ratelimited" }""", HttpStatusCode.TooManyRequests, + r => r.Headers.RetryAfter = new RetryConditionHeaderValue(TimeSpan.FromSeconds(5))); + + var exception = await Assert.ThrowsAnyAsync(() => ClientFor(handler).ChatPostMessage(new ChatPostMessageRequest { Channel = "C123", Text = "hi" })); + + Assert.Equal(TimeSpan.FromSeconds(5), Assert.IsType(exception).RetryAfter); + } +}