Obey Discord rate-limits
authorTheSaminator <TheSaminator@users.noreply.github.com>
Mon, 21 Feb 2022 15:11:03 +0000 (10:11 -0500)
committerTheSaminator <TheSaminator@users.noreply.github.com>
Mon, 21 Feb 2022 15:11:03 +0000 (10:11 -0500)
build.gradle.kts
src/jvmMain/kotlin/starshipfights/auth/providers.kt
src/jvmMain/kotlin/starshipfights/auth/ratelimit.kt [new file with mode: 0644]
src/jvmMain/kotlin/starshipfights/server_conf.kt

index 5e3c20cf6400fa45fbc8ef1b684ec102faeaadf0..4db65c78e44e6655e62bcff37921dfaefc15cac9 100644 (file)
@@ -60,6 +60,10 @@ kotlin {
                                implementation("org.slf4j:slf4j-api:1.7.32")
                                implementation("ch.qos.logback:logback-classic:1.2.10")
                                
+                               implementation("io.github.instantwebp2p:tweetnacl-java:1.1.2")
+                               
+                               implementation("com.aventrix.jnanoid:jnanoid:2.0.0")
+                               
                                implementation("org.litote.kmongo:kmongo-coroutine-serialization:4.4.0") {
                                        exclude("org.jetbrains.kotlin", "kotlin-reflect")
                                        
@@ -68,9 +72,8 @@ kotlin {
                                        exclude("org.jetbrains.kotlinx", "kotlinx-serialization-core-jvm")
                                }
                                
+                               // development only
                                implementation("de.flapdoodle.embed:de.flapdoodle.embed.mongo:3.0.0")
-                               
-                               implementation("com.aventrix.jnanoid:jnanoid:2.0.0")
                        }
                }
                
index 86c74f01315982f95c15fedb094c0799727fed53..23a971410929f767babaf75b728a69a76446845b 100644 (file)
@@ -4,6 +4,7 @@ import io.ktor.application.*
 import io.ktor.auth.*
 import io.ktor.client.*
 import io.ktor.client.engine.apache.*
+import io.ktor.client.features.*
 import io.ktor.client.request.*
 import io.ktor.features.*
 import io.ktor.html.*
@@ -43,9 +44,14 @@ interface AuthProvider {
        fun installRouting(conf: Routing)
        
        companion object Installer {
-               private val currentProvider: AuthProvider
+               private val newCurrentProvider: AuthProvider
                        get() = CurrentConfiguration.discordClient?.let { ProductionAuthProvider(it) } ?: TestAuthProvider
                
+               private var cachedCurrentProvider: AuthProvider? = null
+               
+               val currentProvider: AuthProvider
+                       get() = cachedCurrentProvider ?: newCurrentProvider.also { cachedCurrentProvider = it }
+               
                fun install(into: Application) {
                        currentProvider.installApplication(into)
                        
@@ -490,7 +496,13 @@ object TestAuthProvider : AuthProvider {
 }
 
 class ProductionAuthProvider(private val discordLogin: DiscordLogin) : AuthProvider {
-       private val httpClient = HttpClient(Apache)
+       val httpClient = HttpClient(Apache) {
+               install(UserAgent) {
+                       agent = discordLogin.userAgent
+               }
+               
+               install(RateLimit)
+       }
        
        override fun installAuth(conf: Authentication.Configuration) {
                conf.oauth("auth-oauth-discord") {
diff --git a/src/jvmMain/kotlin/starshipfights/auth/ratelimit.kt b/src/jvmMain/kotlin/starshipfights/auth/ratelimit.kt
new file mode 100644 (file)
index 0000000..987c3d5
--- /dev/null
@@ -0,0 +1,69 @@
+package starshipfights.auth
+
+import io.ktor.client.*
+import io.ktor.client.call.*
+import io.ktor.client.features.*
+import io.ktor.client.request.*
+import io.ktor.client.statement.*
+import io.ktor.http.*
+import io.ktor.util.*
+import kotlinx.coroutines.delay
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+import kotlin.math.roundToLong
+
+class RateLimit(
+       val jsonCodec: Json,
+       val remainingHeader: String,
+       val resetAfterHeader: String,
+) {
+       class Config {
+               var jsonCodec: Json = Json.Default
+               var remainingHeader: String = "X-RateLimit-Remaining"
+               var resetAfterHeader: String = "X-RateLimit-Reset-After"
+       }
+       
+       private var remainingRequests = -1
+       private var resetAfter = 0.0
+       
+       companion object Feature : HttpClientFeature<Config, RateLimit> {
+               override val key: AttributeKey<RateLimit> = AttributeKey("RateLimit")
+               override fun prepare(block: Config.() -> Unit): RateLimit = Config().apply(block).run {
+                       RateLimit(jsonCodec, remainingHeader, resetAfterHeader)
+               }
+               
+               override fun install(feature: RateLimit, scope: HttpClient) {
+                       scope.requestPipeline.intercept(HttpRequestPipeline.Before) {
+                               feature.remainingRequests.takeIf { it >= 0 }?.let { remaining ->
+                                       delay((feature.resetAfter * 1000 / (remaining + 1)).roundToLong())
+                               }
+                       }
+                       
+                       scope.responsePipeline.intercept(HttpResponsePipeline.Receive) {
+                               if (context.response.status == HttpStatusCode.TooManyRequests) {
+                                       feature.remainingRequests = 0
+                                       val jsonBody = context.response.receive<String>()
+                                       val rateLimitedResponse = feature.jsonCodec.decodeFromString(RateLimitedResponse.serializer(), jsonBody)
+                                       feature.resetAfter = rateLimitedResponse.retryAfter
+                               } else {
+                                       context.response.headers[feature.remainingHeader]?.toIntOrNull()?.let {
+                                               feature.remainingRequests = it
+                                       }
+                                       
+                                       context.response.headers[feature.resetAfterHeader]?.toDoubleOrNull()?.let {
+                                               feature.resetAfter = it
+                                       }
+                               }
+                       }
+               }
+       }
+}
+
+@Serializable
+data class RateLimitedResponse(
+       val message: String,
+       @SerialName("retry_after")
+       val retryAfter: Double,
+       val global: Boolean
+)
index c0a58833a7b9600a1bda9a5c657eae075b13d096..fcc4097d63fc3aa54154a9b175a502a413909c56 100644 (file)
@@ -21,8 +21,9 @@ data class Configuration(
 
 @Serializable
 data class DiscordLogin(
+       val userAgent: String,
+       
        val clientId: String,
-       //val clientPubKey: String,
        val clientSecret: String,
        
        val ownerId: String,