/
TwitchHelixRateLimitTracker.java
167 lines (137 loc) · 6.08 KB
/
TwitchHelixRateLimitTracker.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
package com.github.twitch4j.helix.interceptor;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.philippheuer.credentialmanager.domain.OAuth2Credential;
import com.github.twitch4j.common.annotation.Unofficial;
import com.github.twitch4j.common.util.BucketUtils;
import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.jetbrains.annotations.NotNull;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
@RequiredArgsConstructor
@SuppressWarnings("ConstantConditions")
public final class TwitchHelixRateLimitTracker {
/**
* Officially documented rate limit for {@link com.github.twitch4j.helix.TwitchHelix#startRaid(String, String, String)} and {@link com.github.twitch4j.helix.TwitchHelix#cancelRaid(String, String)}
*/
private static final Bandwidth RAIDS_BANDWIDTH = Bandwidth.simple(10, Duration.ofMinutes(10));
/**
* Empirically determined rate limit on helix bans and unbans, per channel
*/
@Unofficial
private static final Bandwidth BANS_BANDWIDTH = Bandwidth.simple(100, Duration.ofSeconds(30));
/**
* Empirically determined rate limit on the helix create clip endpoint, per user
*/
@Unofficial
private static final Bandwidth CLIPS_BANDWIDTH = Bandwidth.simple(600, Duration.ofSeconds(60));
/**
* Empirically determined rate limit on helix add and remove block term, per channel
*/
@Unofficial
private static final Bandwidth TERMS_BANDWIDTH = Bandwidth.simple(60, Duration.ofSeconds(60));
/**
* Rate limit buckets by user/app
*/
private final Cache<String, Bucket> primaryBuckets = Caffeine.newBuilder()
.expireAfterAccess(1, TimeUnit.MINUTES)
.build();
/**
* Raids API: start and cancel raid rate limit buckets per channel
*/
private final Cache<String, Bucket> raidsByChannelId = Caffeine.newBuilder()
.expireAfterAccess(10, TimeUnit.MINUTES)
.build();
/**
* Moderation API: ban and unban rate limit buckets per channel
*/
private final Cache<String, Bucket> bansByChannelId = Caffeine.newBuilder()
.expireAfterAccess(1, TimeUnit.MINUTES)
.build();
/**
* Create Clip API rate limit buckets per user
*/
private final Cache<String, Bucket> clipsByUserId = Caffeine.newBuilder()
.expireAfterAccess(1, TimeUnit.MINUTES)
.build();
/**
* Moderation API: add and remove blocked term rate limit buckets per channel
*/
private final Cache<String, Bucket> termsByChannelId = Caffeine.newBuilder()
.expireAfterAccess(1, TimeUnit.MINUTES)
.build();
/**
* The primary (global helix) rate limit bandwidth to use
*/
private final Bandwidth apiRateLimit;
/**
* Twitch Helix Token Manager
*/
private final TwitchHelixTokenManager tokenManager;
/*
* Primary (global helix) rate limit bucket finder
*/
@NotNull
Bucket getOrInitializeBucket(@NotNull String key) {
return primaryBuckets.get(key, k -> BucketUtils.createBucket(this.apiRateLimit));
}
@NotNull
String getPrimaryBucketKey(@NotNull OAuth2Credential credential) {
// App access tokens share the same bucket for a given client id
// User access tokens share the same bucket for a given client id & user id pair
// For this method to work, credential must have been augmented with information from getAdditionalCredentialInformation (which is done by the interceptor)
// Thus, this logic yields the key that is associated with each primary helix bucket
String clientId = TwitchHelixTokenManager.extractClientId(credential);
return clientId == null ? "" : StringUtils.isEmpty(credential.getUserId()) ? clientId : clientId + "-" + credential.getUserId();
}
/*
* Secondary (endpoint-specific) rate limit buckets
*/
@NotNull
Bucket getRaidsBucket(@NotNull String channelId) {
return raidsByChannelId.get(channelId, k -> BucketUtils.createBucket(RAIDS_BANDWIDTH));
}
@NotNull
@Unofficial
Bucket getModerationBucket(@NotNull String channelId) {
return bansByChannelId.get(channelId, k -> BucketUtils.createBucket(BANS_BANDWIDTH));
}
@NotNull
@Unofficial
Bucket getClipBucket(@NotNull String userId) {
return clipsByUserId.get(userId, k -> BucketUtils.createBucket(CLIPS_BANDWIDTH));
}
@NotNull
@Unofficial
Bucket getTermsBucket(@NotNull String channelId) {
return termsByChannelId.get(channelId, k -> BucketUtils.createBucket(TERMS_BANDWIDTH));
}
/*
* Methods to conservatively update remaining points in rate limit buckets, based on incoming twitch statistics
*/
public void updateRemaining(@NotNull String token, int remaining) {
this.updateRemainingGeneric(token, remaining, this::getPrimaryBucketKey, this::getOrInitializeBucket);
}
public void updateRemainingCreateClip(@NotNull String token, int remaining) {
this.updateRemainingGeneric(token, remaining, OAuth2Credential::getUserId, this::getClipBucket);
}
@Unofficial
public void markDepletedBanBucket(@NotNull String channelId) {
// Called upon a 429 for banUser or unbanUser
Bucket modBucket = this.getModerationBucket(channelId);
modBucket.consumeIgnoringRateLimits(Math.max(modBucket.tryConsumeAsMuchAsPossible(), 1)); // intentionally go negative to induce a pause
}
private void updateRemainingGeneric(String token, int remaining, Function<OAuth2Credential, String> credToKey, Function<String, Bucket> keyToBucket) {
OAuth2Credential credential = tokenManager.getIfPresent(token);
if (credential == null) return;
String key = credToKey.apply(credential);
if (key == null) return;
Bucket bucket = keyToBucket.apply(key);
long diff = bucket.getAvailableTokens() - remaining;
if (diff > 0) bucket.tryConsumeAsMuchAsPossible(diff);
}
}