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
Original file line number Diff line number Diff line change
@@ -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; }
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Net;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
Expand Down Expand Up @@ -40,6 +41,7 @@ public static async Task<T> PostJson<T>(this HttpClient httpClient, object paylo
logger?.Invoke(responseContent);
}

ThrowIfRateLimited(response, responseContent);
response.EnsureSuccessStatusCode();

var resObj = JsonSerializer.Deserialize<T>(responseContent, JsonSerializerSettings);
Expand Down Expand Up @@ -75,6 +77,7 @@ public static async Task<T> PostParametersAsForm<T>(this HttpClient httpClient,
logger?.Invoke($"{response.StatusCode} \n {responseContent}");
}

ThrowIfRateLimited(response, responseContent);
response.EnsureSuccessStatusCode();

var resObj = JsonSerializer.Deserialize<T>(responseContent, JsonSerializerSettings);
Expand Down Expand Up @@ -109,6 +112,7 @@ public static async Task<T> PostParametersAsMultiPartFormData<T>(this HttpClient
logger?.Invoke($"{response.StatusCode} \n {responseContent}");
}

ThrowIfRateLimited(response, responseContent);
response.EnsureSuccessStatusCode();

var resObj = JsonSerializer.Deserialize<T>(responseContent, JsonSerializerSettings);
Expand All @@ -118,6 +122,17 @@ public static async Task<T> PostParametersAsMultiPartFormData<T>(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
Expand Down
7 changes: 7 additions & 0 deletions source/src/Slackbot.Net.SlackClients.Http/ISlackClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ public interface ISlackClient
/// <remarks>https://api.slack.com/methods/users.list</remarks>
Task<UsersListResponse> UsersList();

/// <summary>
/// Scopes required: `users:read`
/// Returns one page of users. Pass the previous page's `response_metadata.next_cursor` to fetch the next page.
/// </summary>
/// <remarks>https://api.slack.com/methods/users.list</remarks>
Task<UsersListResponse> UsersList(string cursor, int limit = 200);

/// <summary>
/// Scopes required: channels:read
/// Only requests `public_channel` types of conversations
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down
14 changes: 14 additions & 0 deletions source/src/Slackbot.Net.SlackClients.Http/SlackClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,20 @@ public async Task<UsersListResponse> UsersList()
return await _client.PostParametersAsForm<UsersListResponse>(null, "users.list", s => _logger.LogTrace(s));
}

/// <inheritdoc/>
public async Task<UsersListResponse> UsersList(string cursor, int limit = 200)
{
var parameters = new List<KeyValuePair<string, string>>
{
new KeyValuePair<string, string>("limit", limit.ToString()),
};
if (!string.IsNullOrEmpty(cursor))
{
parameters.Add(new KeyValuePair<string, string>("cursor", cursor));
}
return await _client.PostParametersAsForm<UsersListResponse>(parameters, "users.list", s => _logger.LogTrace(s));
}

/// <inheritdoc/>
public async Task<ConversationsListResponse> ConversationsListPublicChannels(int? limit = null, string cursor = null)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<HttpResponseMessage> configureResponse = null) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
public string LastRequestContent { get; private set; }

protected override async Task<HttpResponseMessage> 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;
}
}
Original file line number Diff line number Diff line change
@@ -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<ISlackClient>(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<SlackRateLimitedException>(() => 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<SlackRateLimitedException>(() => 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<HttpRequestException>(() => ClientFor(handler).ChatPostMessage(new ChatPostMessageRequest { Channel = "C123", Text = "hi" }));

Assert.Equal(TimeSpan.FromSeconds(5), Assert.IsType<SlackRateLimitedException>(exception).RetryAfter);
}
}
Loading