Skip to content

Commit e9a40cf

Browse files
committed
Implement rate limit protection
1 parent 3b1e4c4 commit e9a40cf

6 files changed

Lines changed: 233 additions & 10 deletions

File tree

README.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ All requests are done through the `ProxerApi` class. You initialize an instance
3535
The most simple initialization looks like this:
3636

3737
```java
38-
ProxerApi api = new ProxerConnection.Builder("yourApiKey").build();
38+
ProxerApi api = new ProxerApi.Builder("yourApiKey").build();
3939
```
4040

4141
You can customize the `ProxerApi` in the following ways:
@@ -204,6 +204,27 @@ ProxerApi api = new ProxerApi.Builder("yourApiKey")
204204

205205
> The token is only stored in memory with the default `LoginTokenManager`. This means, that you lose the login information when the application terminates. You may want to persist it into a `File` or `SharedPreferences` on `Android`.
206206
207+
### Rate limiting
208+
209+
The api has rate limiting in place with individual limits for each api class. If more requests than the limit are performed,
210+
a `ProxerException` (with the `ServerErrorType.CAPTCHA`) is thrown and the user has to solve a captcha. The link to the captcha page can be obtained with
211+
`ProxerUrls.captchaWeb()`.
212+
213+
##### Rate limit protection
214+
215+
Recent versions have a mechanism for protecting against rate limiting implemented. It can be enabled by calling the method:
216+
217+
```java
218+
ProxerApi api = new ProxerApi.Builder("yourApiKey")
219+
.enableRateLimitProtection()
220+
.build();
221+
```
222+
223+
This cancels requests which would trigger the rate limit and throw a `ProxerException` (with the `ServerErrorType.RATE_LIMIT`).
224+
The user does not need to solve the captcha but simply wait then.
225+
226+
> This does not completely captcha errors. If multiple users call from the same network, the rate limit can still be hit and thus the error needs to be handled properly.
227+
207228
### Utils
208229

209230
This library offers two utility classes: `ProxerUrls` and `ProxerUtils`.

library/src/main/kotlin/me/proxer/library/ProxerApi.kt

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import me.proxer.library.internal.interceptor.HeaderInterceptor
3939
import me.proxer.library.internal.interceptor.HttpsEnforcingInterceptor
4040
import me.proxer.library.internal.interceptor.LoginTokenInterceptor
4141
import me.proxer.library.internal.interceptor.OneShotInterceptor
42+
import me.proxer.library.internal.interceptor.RateLimitInterceptor
4243
import me.proxer.library.util.ProxerUrls
4344
import okhttp3.CertificatePinner
4445
import okhttp3.OkHttpClient
@@ -181,6 +182,7 @@ class ProxerApi private constructor(retrofit: Retrofit) {
181182
private var moshi: Moshi? = null
182183
private var client: OkHttpClient? = null
183184
private var retrofit: Retrofit? = null
185+
private var enableRateLimitProtection: Boolean = false
184186

185187
/**
186188
* Sets a custom login token manager.
@@ -220,35 +222,45 @@ class ProxerApi private constructor(retrofit: Retrofit) {
220222
*/
221223
fun retrofit(retrofit: Retrofit) = this.apply { this.retrofit = retrofit }
222224

225+
/**
226+
* Enables the rate limit protection to avoid users having to fill out captchas.
227+
*/
228+
fun enableRateLimitProtection() = this.apply { this.enableRateLimitProtection = true }
229+
223230
/**
224231
* Finally builds the [ProxerApi] with the provided adjustments.
225232
*/
226233
fun build(): ProxerApi {
227-
return ProxerApi(buildRetrofit())
234+
val moshi = buildMoshi()
235+
236+
return ProxerApi(buildRetrofit(moshi))
228237
}
229238

230-
private fun buildRetrofit(): Retrofit {
239+
private fun buildRetrofit(moshi: Moshi): Retrofit {
231240
return (retrofit?.newBuilder() ?: Retrofit.Builder())
232241
.baseUrl(ProxerUrls.apiBase)
233-
.client(buildClient())
242+
.client(buildClient(moshi))
234243
.addCallAdapterFactory(ProxerResponseCallAdapterFactory())
235-
.addConverterFactory(MoshiConverterFactory.create(buildMoshi()))
244+
.addConverterFactory(MoshiConverterFactory.create(moshi))
236245
.addConverterFactory(EnumRetrofitConverterFactory())
237246
.build()
238247
}
239248

240-
private fun buildClient(): OkHttpClient {
249+
private fun buildClient(moshi: Moshi): OkHttpClient {
241250
return (client?.newBuilder() ?: OkHttpClient.Builder())
242251
.apply {
243252
interceptors().apply {
244253
addAll(
245-
0, listOf(
254+
0,
255+
listOf(
246256
HeaderInterceptor(apiKey, buildUserAgent()),
247257
LoginTokenInterceptor(buildLoginTokenManager()),
248258
HttpsEnforcingInterceptor(),
249259
OneShotInterceptor()
250260
)
251261
)
262+
263+
if (enableRateLimitProtection) add(0, RateLimitInterceptor(moshi))
252264
}
253265

254266
certificatePinner(constructCertificatePinner())

library/src/main/kotlin/me/proxer/library/ProxerException.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ class ProxerException : Exception {
157157
COMMENT_ALREADY_EXISTS(3078),
158158

159159
UNKNOWN(10_000),
160+
RATE_LIMIT(99_998),
160161
INTERNAL(99_999);
161162

162163
companion object {

library/src/main/kotlin/me/proxer/library/internal/interceptor/LoginTokenInterceptor.kt

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import me.proxer.library.ProxerException.ServerErrorType
77
import me.proxer.library.util.ProxerUrls
88
import okhttp3.Interceptor
99
import okhttp3.Response
10+
import java.util.concurrent.locks.ReentrantLock
11+
import kotlin.concurrent.withLock
1012

1113
/**
1214
* @author Ruben Gees
@@ -31,7 +33,7 @@ internal class LoginTokenInterceptor(private val loginTokenManager: LoginTokenMa
3133
.build()
3234
}
3335

34-
private val lock = Any()
36+
private val lock = ReentrantLock()
3537

3638
override fun intercept(chain: Interceptor.Chain): Response {
3739
val oldRequest = chain.request()
@@ -40,7 +42,7 @@ internal class LoginTokenInterceptor(private val loginTokenManager: LoginTokenMa
4042
val newRequestBuilder = oldRequest.newBuilder()
4143

4244
if (oldRequest.url != LOGIN_URL) {
43-
val loginToken = synchronized(lock) {
45+
val loginToken = lock.withLock {
4446
loginTokenManager.provide()
4547
}
4648

@@ -59,7 +61,7 @@ internal class LoginTokenInterceptor(private val loginTokenManager: LoginTokenMa
5961
if (response.isSuccessful) {
6062
val (errorCode, token) = peekResponse(response)
6163

62-
synchronized(lock) {
64+
lock.withLock {
6365
val errorType = errorCode?.let { ServerErrorType.fromErrorCode(it) }
6466

6567
if (errorType != null) {
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package me.proxer.library.internal.interceptor
2+
3+
import com.squareup.moshi.Moshi
4+
import com.squareup.moshi.Types
5+
import me.proxer.library.ProxerException.ServerErrorType.RATE_LIMIT
6+
import me.proxer.library.internal.ProxerResponse
7+
import me.proxer.library.util.ProxerUrls
8+
import okhttp3.Interceptor
9+
import okhttp3.MediaType.Companion.toMediaTypeOrNull
10+
import okhttp3.Protocol
11+
import okhttp3.Request
12+
import okhttp3.Response
13+
import okhttp3.ResponseBody.Companion.toResponseBody
14+
import java.util.Date
15+
import java.util.concurrent.locks.ReentrantReadWriteLock
16+
import kotlin.concurrent.read
17+
import kotlin.concurrent.write
18+
19+
internal class RateLimitInterceptor(private val moshi: Moshi) : Interceptor {
20+
21+
private companion object {
22+
private const val WAIT_THRESHOLD = 2_000
23+
24+
private val errorMediaType = "application/json".toMediaTypeOrNull()
25+
private val responseType = Types.newParameterizedType(ProxerResponse::class.java, Unit::class.java)
26+
27+
private val limits = mapOf(
28+
"anime" to Limit(20, 360_000),
29+
"apps" to Limit(100, 30_000), // Not enforced by api.
30+
"chat" to Limit(30, 30_000),
31+
"comment" to Limit(100, 30_000), // Not enforced by api.
32+
"files" to Limit(30, 100_000),
33+
"forum" to Limit(100, 30_000), // Not enforced by api.
34+
"info" to Limit(60, 360_000),
35+
"list" to Limit(60, 60_000),
36+
"manga" to Limit(10, 300_000),
37+
"media" to Limit(100, 30_000), // Not enforced by api.
38+
"messenger" to Limit(100, 50_000),
39+
"misc" to Limit(100, 30_000), // Not enforced by api.
40+
"notifications" to Limit(100, 30_000), // Not enforced by api.
41+
"ucp" to Limit(40, 60_000),
42+
"user" to Limit(40, 360_000),
43+
"users" to Limit(40, 360_000),
44+
"wiki" to Limit(100, 30_000) // Not enforced by api.
45+
)
46+
}
47+
48+
private var state = limits.mapValues { State(0, null) }
49+
50+
private val lock = ReentrantReadWriteLock()
51+
52+
@Suppress("ReturnCount")
53+
override fun intercept(chain: Interceptor.Chain): Response {
54+
val oldRequest = chain.request()
55+
56+
if (oldRequest.url.host != ProxerUrls.apiBase.host) {
57+
return chain.proceed(oldRequest)
58+
}
59+
60+
val apiClass = oldRequest.url.pathSegments.getOrNull(2)
61+
val limit = limits[apiClass]
62+
63+
if (apiClass == null || limit == null) {
64+
return chain.proceed(oldRequest)
65+
}
66+
67+
return intercept(apiClass, limit, chain, oldRequest)
68+
}
69+
70+
@Suppress("ReturnCount")
71+
private fun intercept(
72+
apiClass: String,
73+
limit: Limit,
74+
chain: Interceptor.Chain,
75+
oldRequest: Request
76+
): Response {
77+
val (requests, firstRequest) = lock.read { state.getValue(apiClass) }
78+
val timeDifferenceMillis = if (firstRequest != null) Date().time - firstRequest.time else null
79+
80+
if (timeDifferenceMillis == null || timeDifferenceMillis > limit.millis) {
81+
lock.write { state = state.update(apiClass, 1, Date()) }
82+
83+
return chain.proceed(oldRequest)
84+
} else if (requests + 2 >= limit.maxRequests) {
85+
return if ((limit.millis - timeDifferenceMillis) < WAIT_THRESHOLD) {
86+
Thread.sleep(timeDifferenceMillis)
87+
88+
intercept(chain)
89+
} else {
90+
val errorBody = moshi
91+
.adapter<ProxerResponse<Unit?>>(responseType)
92+
.toJson(ProxerResponse(true, "Rate limit", RATE_LIMIT.code, null))
93+
94+
Response.Builder()
95+
.body(errorBody.toResponseBody(errorMediaType))
96+
.protocol(Protocol.HTTP_1_1)
97+
.request(oldRequest)
98+
.code(200)
99+
.message("")
100+
.build()
101+
}
102+
} else {
103+
lock.write { state = state.update(apiClass, requests + 1, firstRequest) }
104+
105+
return chain.proceed(oldRequest)
106+
}
107+
}
108+
109+
private fun Map<String, State>.update(apiClass: String, requests: Int, firstRequest: Date?): Map<String, State> {
110+
return toMutableMap().apply { put(apiClass, State(requests, firstRequest)) }
111+
}
112+
113+
private data class Limit(val maxRequests: Int, val millis: Int)
114+
private data class State(val requests: Int, val firstRequest: Date?)
115+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package me.proxer.library.internal.interceptor
2+
3+
import me.proxer.library.ProxerApi
4+
import me.proxer.library.ProxerException
5+
import me.proxer.library.ProxerException.ServerErrorType
6+
import me.proxer.library.ProxerTest
7+
import me.proxer.library.enums.Language
8+
import me.proxer.library.runRequest
9+
import org.amshove.kluent.invoking
10+
import org.amshove.kluent.shouldBe
11+
import org.amshove.kluent.shouldThrow
12+
import org.junit.jupiter.api.Disabled
13+
import org.junit.jupiter.api.Test
14+
15+
class RateLimitInterceptorTest : ProxerTest() {
16+
17+
private val rateLimitApi = ProxerApi.Builder("mock-key")
18+
.enableRateLimitProtection()
19+
.client(client)
20+
.build()
21+
22+
@Test
23+
fun testSingleRequest() {
24+
server.runRequest("chapter.json") {
25+
rateLimitApi.manga.chapter("123", 123, Language.ENGLISH).build().execute()
26+
}
27+
}
28+
29+
@Test
30+
fun testRateLimitError() {
31+
// Limit 10.
32+
repeat(8) {
33+
server.runRequest("chapter.json") {
34+
rateLimitApi.manga.chapter("123", 123, Language.ENGLISH).build().execute()
35+
}
36+
}
37+
38+
val result = invoking {
39+
server.runRequest("chapter.json") {
40+
rateLimitApi.manga.chapter("123", 123, Language.ENGLISH).build().execute()
41+
}
42+
} shouldThrow ProxerException::class
43+
44+
result.exception.errorType shouldBe ProxerException.ErrorType.SERVER
45+
result.exception.serverErrorType shouldBe ServerErrorType.RATE_LIMIT
46+
}
47+
48+
@Test
49+
@Disabled
50+
fun testResetStateAfterTimeLimit() {
51+
// Limit 30.
52+
repeat(28) {
53+
server.runRequest("chat_messages.json") {
54+
rateLimitApi.chat.messages("123").build().execute()
55+
}
56+
}
57+
58+
val result = invoking {
59+
server.runRequest("chat_messages.json") {
60+
rateLimitApi.chat.messages("123").build().execute()
61+
}
62+
} shouldThrow ProxerException::class
63+
64+
result.exception.serverErrorType shouldBe ServerErrorType.RATE_LIMIT
65+
66+
Thread.sleep(30_000)
67+
68+
server.runRequest("chat_messages.json") {
69+
rateLimitApi.chat.messages("123").build().execute()
70+
}
71+
}
72+
}

0 commit comments

Comments
 (0)