Skip to content

Commit 4a31b62

Browse files
committed
better rate limited responses
allows clients to use `ratelimit.key` and `ratelimit.seconds` to better handle limited responses ```json { "error": "You played 100 games against other bots today, please wait before challenging another bot.", "ratelimit": { "key": "bot.vsBot.day", "seconds": 86398 } } ```
1 parent 3b173c0 commit 4a31b62

File tree

3 files changed

+25
-0
lines changed

3 files changed

+25
-0
lines changed

modules/memo/src/main/RateLimit.scala

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,17 @@ final class RateLimit[K](
5353
def zero[A](k: K, cost: Cost = 1, msg: => String = "")(op: => A)(using default: Zero[A]): A =
5454
apply[A](k, default.zero, cost, msg)(op)
5555

56+
def isLimited(k: K): Option[Instant] =
57+
enforce.yes
58+
.so(storage.getIfPresent(k))
59+
.flatMap:
60+
case (a, _) if a < credits => none
61+
case (_, clearAt) if nowMillis > clearAt => none
62+
case (_, clearAt) =>
63+
if log then logger.info(s"$credits/$duration $k")
64+
monitor.increment()
65+
millisToInstant(clearAt).some
66+
5667
object RateLimit:
5768

5869
type ChargeWith = Cost => Unit
@@ -62,6 +73,8 @@ object RateLimit:
6273
enum Result:
6374
case Through, Limited
6475

76+
case class Limited(key: String, msg: String, until: Instant)
77+
6578
trait RateLimiter[K]:
6679
def apply[A](k: K, default: => A, cost: Cost = 1, msg: => String = "")(op: => A): A
6780
def chargeable[A](k: K, default: => A, cost: Cost = 1, msg: => String = "")(op: ChargeWith => A): A

modules/web/src/main/CtrlErrors.scala

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,15 @@ import lila.core.i18n.{ I18nKey, Translate }
88

99
trait CtrlErrors extends ControllerHelpers:
1010

11+
given Writes[lila.memo.RateLimit.Limited] = Writes: l =>
12+
Json.obj(
13+
"error" -> l.msg,
14+
"ratelimit" -> Json.obj(
15+
"key" -> l.key,
16+
"seconds" -> (l.until.toSeconds - nowSeconds).toInt.atLeast(0)
17+
)
18+
)
19+
1120
def jsonError[A: Writes](err: A): JsObject = Json.obj("error" -> err)
1221

1322
def notFoundJson(msg: String = "Not found"): Result = NotFound(jsonError(msg)).as(JSON)

modules/web/src/main/ResponseBuilder.scala

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import play.api.http.*
55
import play.api.libs.json.*
66
import play.api.mvc.*
77

8+
import lila.memo.RateLimit.Limited
9+
810
trait ResponseBuilder(using Executor)
911
extends ControllerHelpers
1012
with ResponseWriter
@@ -30,6 +32,7 @@ trait ResponseBuilder(using Executor)
3032
def JsonStrOk(str: JsonStr): Result = Ok(str).as(JSON)
3133
def JsonBadRequest(body: JsValue): Result = BadRequest(body).as(JSON)
3234
def JsonBadRequest(msg: String): Result = JsonBadRequest(jsonError(msg))
35+
def JsonLimited(limited: Limited): Result = TooManyRequests(Json.toJson(limited)).as(JSON)
3336

3437
def strToNdJson(source: Source[String, ?]): Result =
3538
Ok.chunked(source).as(ndJson.contentType).noProxyBuffer

0 commit comments

Comments
 (0)