Skip to content

Commit

Permalink
Merge pull request #136 from abitofevrything/feat-implement-http-rate…
Browse files Browse the repository at this point in the history
…-limit

feat: Implement http rate limit
  • Loading branch information
LeadcodeDev authored Sep 14, 2023
2 parents f75ee70 + 0449da0 commit ac47101
Show file tree
Hide file tree
Showing 21 changed files with 313 additions and 58 deletions.
3 changes: 2 additions & 1 deletion lib/internal/kernel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,10 @@ final class Kernel {
final config = http(environment);

this.http = container.bind<DiscordHttpClient>('http', (_) =>
DiscordHttpClient(baseUrl: '${config.baseUrl}/v${config.version}')
DiscordHttpClient(logger: logger, baseUrl: '${config.baseUrl}/v${config.version}')
..headers.setContentType('application/json')
..headers.setAuthorization('Bot $token')
..headers.setUserAgent('Mineral')
);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'package:http/http.dart';
import 'package:mineral/internal/either.dart';
import 'package:mineral/internal/services/http/discord_http_request_dispatcher.dart';
import 'package:mineral/services/http/builders/delete_builder.dart';
import 'package:mineral/services/http/http_request_dispatcher.dart';
import 'package:mineral/services/http/method_adapter.dart';
Expand All @@ -10,7 +11,7 @@ import 'package:mineral/services/http/method_adapter.dart';
/// await client.delete('/foo').build();
/// ```
class DiscordDeleteBuilder extends DeleteBuilder implements MethodAdapter {
final HttpRequestDispatcher _dispatcher;
final DiscordHttpRequestDispatcher _dispatcher;
final Map<String, String> _headers = {};
final Request _request;

Expand Down
3 changes: 2 additions & 1 deletion lib/internal/services/http/builders/discord_get_builder.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'package:http/http.dart';
import 'package:mineral/internal/either.dart';
import 'package:mineral/internal/services/http/discord_http_request_dispatcher.dart';
import 'package:mineral/services/http/builders/get_builder.dart';
import 'package:mineral/services/http/http_client.dart';
import 'package:mineral/services/http/http_request_dispatcher.dart';
Expand All @@ -11,7 +12,7 @@ import 'package:mineral/services/http/method_adapter.dart';
/// final foo = await client.get('/foo').build();
/// ```
class DiscordGetBuilder extends GetBuilder implements MethodAdapter {
final HttpRequestDispatcher _dispatcher;
final DiscordHttpRequestDispatcher _dispatcher;
final Map<String, String> _headers = {};
final Request _request;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'dart:convert';

import 'package:http/http.dart';
import 'package:mineral/internal/either.dart';
import 'package:mineral/internal/services/http/discord_http_request_dispatcher.dart';
import 'package:mineral/services/http/builders/patch_builder.dart';
import 'package:mineral/services/http/http_client.dart';
import 'package:mineral/services/http/http_request_dispatcher.dart';
Expand All @@ -17,7 +18,7 @@ import 'package:mineral/services/http/method_adapter.dart';
/// .build();
/// ```
class DiscordPatchBuilder extends PatchBuilder implements MethodAdapter {
final HttpRequestDispatcher _dispatcher;
final DiscordHttpRequestDispatcher _dispatcher;
final Map<String, String> _headers = {};
final Request _request;
final List<MultipartFile> _files = [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'dart:convert';

import 'package:http/http.dart';
import 'package:mineral/internal/either.dart';
import 'package:mineral/internal/services/http/discord_http_request_dispatcher.dart';
import 'package:mineral/services/http/builders/post_builder.dart';
import 'package:mineral/services/http/http_client.dart';
import 'package:mineral/services/http/http_request_dispatcher.dart';
Expand All @@ -17,7 +18,7 @@ import 'package:mineral/services/http/method_adapter.dart';
/// .build();
/// ```
class DiscordPostBuilder extends PostBuilder implements MethodAdapter {
final HttpRequestDispatcher _dispatcher;
final DiscordHttpRequestDispatcher _dispatcher;
final Map<String, String> _headers = {};
final Request _request;
final List<MultipartFile> _files = [];
Expand Down
3 changes: 2 additions & 1 deletion lib/internal/services/http/builders/discord_put_builder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'dart:convert';

import 'package:http/http.dart';
import 'package:mineral/internal/either.dart';
import 'package:mineral/internal/services/http/discord_http_request_dispatcher.dart';
import 'package:mineral/services/http/builders/put_builder.dart';
import 'package:mineral/services/http/http_client.dart';
import 'package:mineral/services/http/http_request_dispatcher.dart';
Expand All @@ -17,7 +18,7 @@ import 'package:mineral/services/http/method_adapter.dart';
/// .build();
/// ```
class DiscordPutBuilder extends PutBuilder implements MethodAdapter {
final HttpRequestDispatcher _dispatcher;
final DiscordHttpRequestDispatcher _dispatcher;
final Map<String, String> _headers = {};
final Request _request;
final List<MultipartFile> _files = [];
Expand Down
109 changes: 109 additions & 0 deletions lib/internal/services/http/discord_http_bucket.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import 'package:http/http.dart' as http;
import 'package:mineral/internal/services/http/rate_limit.dart';
import 'package:mineral/services/http/http_response.dart';

final class DiscordHttpBucket {
final List<http.BaseRequest> _pendingRequests = [];
final String bucketId;

/// The number of requests currently pending in this bucket.
int get pendingRequests => _pendingRequests.length;

int _remaining;
DateTime _resetAt;

/// The number of requests remaining in this bucket.
int get remaining => _remaining;

/// The time at which this bucket resets.
DateTime get resetAt => _resetAt;

/// The duration after which this bucket will reset.
Duration get resetAfter => DateTime.now().difference(resetAt);

/// Whether this bucket is ready to sent a new request.
bool get isReady => remaining - pendingRequests > 0;

DiscordHttpBucket._({
required this.bucketId,
required int remaining,
required double resetAfter,
}) : _resetAt = _resetAtFromAfter(resetAfter),
_remaining = remaining;

factory DiscordHttpBucket.global({required double resetAfter}) => DiscordHttpBucket._(
bucketId: '__GLOBAL__',
remaining: 1, // Requests are never counted against the global limit
resetAfter: resetAfter,
);

void addPendingRequest(http.BaseRequest request) {
_pendingRequests.add(request);
}

void removePendingRequest(http.BaseRequest request) {
_pendingRequests.remove(request);
}

/// Return a future that completes once this bucket may be ready to send a new request.
///
/// If many requests are waiting on this bucket, requests might still need to wait longer before
/// being sent. Check [isReady] to see if the request can be sent.
Future<void> wait() => resetAfter.isNegative
? Future.delayed(const Duration(milliseconds: 50))
: Future.delayed(resetAfter);

void updateRateLimit(HttpResponse response) {
if (!inBucket(response)) {
return;
}

_remaining = _getHeader<int>(RateLimit.xRateLimitRemaining, response.headers) ?? _remaining;

final resetAfter = _getHeader<double>(RateLimit.xRateLimitResetAfter, response.headers) ??
_getHeader<double>(RateLimit.retryAfter, response.headers);
if (resetAfter != null) {
_resetAt = _resetAtFromAfter(resetAfter);
}
}

bool inBucket(HttpResponse response) =>
_getHeader(RateLimit.xRateLimitBucket, response.headers) != null;

static T? _getHeader<T>(RateLimit rateLimit, Map<String, String> headers) {
final value = headers[rateLimit.value];

if (value == null) {
return null;
}

return switch (T) {
int => int.tryParse(value),
double => double.tryParse(value),
bool => bool.tryParse(value),
_ => value
} as T?;
}

static DateTime _resetAtFromAfter(double resetAfter) => DateTime.now().add(
Duration(microseconds: (resetAfter * Duration.microsecondsPerSecond).ceil()),
);

static DiscordHttpBucket? fromResponse(HttpResponse response) {
final bucketId = _getHeader<String>(RateLimit.xRateLimitBucket, response.headers);
final remaining = _getHeader<int>(RateLimit.xRateLimitRemaining, response.headers);
final reset = _getHeader<double>(RateLimit.xRateLimitReset, response.headers);
final resetAfter = _getHeader<double>(RateLimit.xRateLimitResetAfter, response.headers);
final limit = _getHeader<int>(RateLimit.xRateLimitLimit, response.headers);

if ([bucketId, remaining, reset, resetAfter, limit].contains(null)) {
return null;
}

return DiscordHttpBucket._(
bucketId: bucketId!,
remaining: remaining!,
resetAfter: resetAfter!,
);
}
}
32 changes: 16 additions & 16 deletions lib/internal/services/http/discord_http_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,27 @@ import 'package:mineral/internal/services/http/builders/discord_get_builder.dart
import 'package:mineral/internal/services/http/builders/discord_patch_builder.dart';
import 'package:mineral/internal/services/http/builders/discord_post_builder.dart';
import 'package:mineral/internal/services/http/builders/discord_put_builder.dart';
import 'package:mineral/internal/services/http/discord_http_request_dispatcher.dart';
import 'package:mineral/internal/services/http/discord_endpoint_repository.dart';
import 'package:mineral/services/http/header_bucket.dart';
import 'package:mineral/services/http/http_client_contract.dart';
import 'package:mineral/services/http/http_request_dispatcher.dart';
import 'package:mineral/services/http/contracts/http_client_contract.dart';
import 'package:mineral/services/logger/logger_contract.dart';

/// Discord HTTP Client used to make requests to a Discord API.
/// Related to the official Discord API documentation: https://discord.com/developers/docs/intro
/// ```dart
/// final DiscordHttpClient client = DiscordHttpClient(baseUrl: '/');
/// ```
class DiscordHttpClient extends Injectable implements HttpClientContract {
class DiscordHttpClient extends Injectable
implements HttpClientContract<DiscordHttpRequestDispatcher> {
/// Client used to make requests
final Client _client = Client();

final DiscordEndpointRepository endpoints = DiscordEndpointRepository();

/// Dispatcher used to dispatch requests under pools
@override
late final HttpRequestDispatcher dispatcher;
late final DiscordHttpRequestDispatcher dispatcher;

/// Base URL of this
@override
Expand All @@ -33,8 +35,11 @@ class DiscordHttpClient extends Injectable implements HttpClientContract {
@override
final HeaderBucket headers = HeaderBucket();

DiscordHttpClient({ required this.baseUrl, Map<String, String> headers = const {} }) {
dispatcher = HttpRequestDispatcher(_client);
DiscordHttpClient(
{required this.baseUrl,
required LoggerContract logger,
Map<String, String> headers = const {}}) {
dispatcher = DiscordHttpRequestDispatcher(_client);
this.headers.addAll(headers);
}

Expand All @@ -45,8 +50,7 @@ class DiscordHttpClient extends Injectable implements HttpClientContract {
/// ```
@override
DiscordGetBuilder get(String url) {
final request = Request('GET', Uri.parse('$baseUrl$url'))
..headers.addAll(headers.all);
final request = Request('GET', Uri.parse('$baseUrl$url'))..headers.addAll(headers.all);

return DiscordGetBuilder(dispatcher, request);
}
Expand All @@ -60,8 +64,7 @@ class DiscordHttpClient extends Injectable implements HttpClientContract {
/// ```
@override
DiscordPostBuilder post(String url) {
final request = Request('POST', Uri.parse('$baseUrl$url'))
..headers.addAll(headers.all);
final request = Request('POST', Uri.parse('$baseUrl$url'))..headers.addAll(headers.all);

return DiscordPostBuilder(dispatcher, request);
}
Expand All @@ -75,8 +78,7 @@ class DiscordHttpClient extends Injectable implements HttpClientContract {
/// ```
@override
DiscordPutBuilder put(String url) {
final request = Request('PUT', Uri.parse('$baseUrl$url'))
..headers.addAll(headers.all);
final request = Request('PUT', Uri.parse('$baseUrl$url'))..headers.addAll(headers.all);

return DiscordPutBuilder(dispatcher, request);
}
Expand All @@ -90,8 +92,7 @@ class DiscordHttpClient extends Injectable implements HttpClientContract {
/// ```
@override
DiscordPatchBuilder patch(String url) {
final request = Request('PATCH', Uri.parse('$baseUrl$url'))
..headers.addAll(headers.all);
final request = Request('PATCH', Uri.parse('$baseUrl$url'))..headers.addAll(headers.all);

return DiscordPatchBuilder(dispatcher, request);
}
Expand All @@ -103,8 +104,7 @@ class DiscordHttpClient extends Injectable implements HttpClientContract {
/// ```
@override
DiscordDeleteBuilder delete(String url) {
final request = Request('DELETE', Uri.parse('$baseUrl$url'))
..headers.addAll(headers.all);
final request = Request('DELETE', Uri.parse('$baseUrl$url'))..headers.addAll(headers.all);

return DiscordDeleteBuilder(dispatcher, request);
}
Expand Down
Loading

0 comments on commit ac47101

Please sign in to comment.