From ab4339b00e1818396792069074c3779e9cd2c8a8 Mon Sep 17 00:00:00 2001 From: davfsa Date: Sat, 21 Jun 2025 10:01:46 +0200 Subject: [PATCH 001/105] Implement sliding window ratelimiting --- go.mod | 53 +++++----- go.sum | 127 ++++++++++++++---------- lib/discord.go | 9 +- lib/queue.go | 242 ++++++++++++++++++++++++---------------------- lib/ratelimits.go | 165 +++++++++++++++++++++++++++++++ main.go | 2 +- 6 files changed, 403 insertions(+), 195 deletions(-) create mode 100644 lib/ratelimits.go diff --git a/go.mod b/go.mod index d25e63e..b455dae 100644 --- a/go.mod +++ b/go.mod @@ -1,36 +1,39 @@ module github.com/germanoeich/nirn-proxy -go 1.18 +go 1.23.0 + +toolchain go1.24.4 require ( github.com/Clever/leakybucket v1.2.0 - github.com/hashicorp/golang-lru v0.5.4 - github.com/hashicorp/memberlist v0.3.1 - github.com/joho/godotenv v1.4.0 - github.com/prometheus/client_golang v1.11.0 - github.com/sirupsen/logrus v1.8.1 + github.com/hashicorp/golang-lru v1.0.2 + github.com/hashicorp/memberlist v0.5.3 + github.com/joho/godotenv v1.5.1 + github.com/prometheus/client_golang v1.22.0 + github.com/sirupsen/logrus v1.9.3 ) require ( - github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da // indirect + github.com/armon/go-metrics v0.4.1 // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/cespare/xxhash/v2 v2.1.1 // indirect - github.com/golang/protobuf v1.4.3 // indirect - github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c // indirect - github.com/hashicorp/errwrap v1.0.0 // indirect - github.com/hashicorp/go-immutable-radix v1.0.0 // indirect - github.com/hashicorp/go-msgpack v0.5.3 // indirect - github.com/hashicorp/go-multierror v1.0.0 // indirect - github.com/hashicorp/go-sockaddr v1.0.0 // indirect - github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect - github.com/miekg/dns v1.1.26 // indirect - github.com/prometheus/client_model v0.2.0 // indirect - github.com/prometheus/common v0.26.0 // indirect - github.com/prometheus/procfs v0.6.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-immutable-radix v1.3.1 // indirect + github.com/hashicorp/go-metrics v0.5.4 // indirect + github.com/hashicorp/go-msgpack/v2 v2.1.3 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-sockaddr v1.0.7 // indirect + github.com/miekg/dns v1.1.66 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.62.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect - golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 // indirect - golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4 // indirect - golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40 // indirect - golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect - google.golang.org/protobuf v1.26.0-rc.1 // indirect + golang.org/x/mod v0.25.0 // indirect + golang.org/x/net v0.41.0 // indirect + golang.org/x/sync v0.15.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/tools v0.34.0 // indirect + google.golang.org/protobuf v1.36.5 // indirect ) diff --git a/go.sum b/go.sum index fd2c392..573d172 100644 --- a/go.sum +++ b/go.sum @@ -1,20 +1,24 @@ cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= github.com/Clever/leakybucket v1.2.0 h1:tj9bHR6QS6c5Crszv+EP66NcbJxLabwZ90CUqNlFsSw= github.com/Clever/leakybucket v1.2.0/go.mod h1:gZbI9EI3nNh9loJzrwobjtPUh3fuOT2Q6GgqtBHFuc4= +github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= -github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da h1:8GUt8eRujhVEGZFFEjBj46YV4rDjvGrNxb0KMWYkL2I= -github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= +github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA= +github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4= github.com/aws/aws-sdk-go v1.29.31/go.mod h1:1KvfttTE3SPKMpo8g2c6jL3ZKfXtFvKscTgahTma5Xg= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= +github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -38,61 +42,73 @@ github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrU github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.4.3 h1:JjCZWpVbqXDqFVmTfYWEVTMIYrL/NPdPSCHPJ0T/raM= github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c h1:964Od4U6p2jUkFxvCydnIczKteheJEzHRToSGK3Bnlw= -github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/go-immutable-radix v1.0.0 h1:AKDB1HM5PWEA7i4nhcpwOrO2byshxBjXVn/J/3+z5/0= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= -github.com/hashicorp/go-msgpack v0.5.3 h1:zKjpN5BK/P5lMYrLmBHdBULWbJ0XpYR+7NGzqkZzoD4= -github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= -github.com/hashicorp/go-multierror v1.0.0 h1:iVjPR7a6H0tWELX5NxNe7bYopibicUzc7uPribsnS6o= -github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= -github.com/hashicorp/go-sockaddr v1.0.0 h1:GeH6tui99pF4NJgfnhp+L6+FfobzVW3Ah46sLo0ICXs= -github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= +github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= +github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-metrics v0.5.4 h1:8mmPiIJkTPPEbAiV97IxdAGNdRdaWwVap1BU6elejKY= +github.com/hashicorp/go-metrics v0.5.4/go.mod h1:CG5yz4NZ/AI/aQt9Ucm/vdBnbh7fvmv4lxZ350i+QQI= +github.com/hashicorp/go-msgpack/v2 v2.1.3 h1:cB1w4Zrk0O3jQBTcFMKqYQWRFfsSQ/TYKNyUUVyCP2c= +github.com/hashicorp/go-msgpack/v2 v2.1.3/go.mod h1:SjlwKKFnwBXvxD/I1bEcfJIBbEJ+MCUn39TxymNR5ZU= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= +github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= +github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= github.com/hashicorp/go-uuid v1.0.0 h1:RS8zrF7PhGwyNPOtxSClXXj9HA8feRnJzgnI1RJCSnM= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= -github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= -github.com/hashicorp/memberlist v0.3.1 h1:MXgUXLqva1QvpVEDQW1IQLG0wivQAtmFlHRQ+1vWZfM= -github.com/hashicorp/memberlist v0.3.1/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE= +github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= +github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hashicorp/memberlist v0.5.3 h1:tQ1jOCypD0WvMemw/ZhhtH+PWpzcftQvgCorLu0hndk= +github.com/hashicorp/memberlist v0.5.3/go.mod h1:h60o12SZn/ua/j0B6iKAZezA4eDaGsIuPO70eOaJ6WE= github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= -github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg= -github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= -github.com/miekg/dns v1.1.26 h1:gPxPSwALAeHJSjarOs00QjVdV9QoBvc1D2ujQUr5BzU= -github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= +github.com/miekg/dns v1.1.66 h1:FeZXOS3VCVsKnEAd+wBkjMC3D2K+ww66Cq3VnCINuJE= +github.com/miekg/dns v1.1.66/go.mod h1:jGFzBsSNbJw6z1HYut1RKBKHA9PBdxeHrZG8J+gC2WE= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c h1:Lgl0gzECD8GnQ5QCWA8o6BtfL6mDH5rQgM4/fX3avOs= -github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY= +github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -100,86 +116,93 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= -github.com/prometheus/client_golang v1.11.0 h1:HNkLOAEQMIDv/K+04rukrLx6ch7msSRwf3/SASFAGtQ= -github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= +github.com/prometheus/client_golang v1.11.1/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= +github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= +github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M= github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= -github.com/prometheus/common v0.26.0 h1:iMAkS2TDoNWnKM+Kopnx/8tnEStIfpYA0ur0xQzzhMQ= github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= +github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= +github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= -github.com/prometheus/procfs v0.6.0 h1:mxy4L2jP6qMonqmq+aTtOx1ifVWUgG/TAmntgbh3xv4= github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= -github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= -github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4 h1:4nGaVu0QrbjT/AK2PRLuQfQuh6DJve+pELhqTdAj3x0= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= +golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201207232520-09787c993a3a h1:DcqTD9SDLc+1P/r1EmRBwnVsrOwW+kk2vWf9n+1sGhs= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40 h1:JWgyZ1qgdTaF3N3oxC+MdTV7qvEEgHo3otj+HB5CM7Q= golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= @@ -187,8 +210,9 @@ google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQ google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.26.0-rc.1 h1:7QnIQpGRHE5RnLKnESfDoxm2dTapTZua5a0kS0A+VXQ= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= +google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -197,5 +221,6 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/lib/discord.go b/lib/discord.go index cf99f86..89f0870 100644 --- a/lib/discord.go +++ b/lib/discord.go @@ -7,7 +7,6 @@ import ( "errors" "github.com/sirupsen/logrus" "io" - "io/ioutil" "math" "net" "net/http" @@ -88,7 +87,7 @@ func createTransport(ip string, disableHttp2 bool) http.RoundTripper { } func parseGlobalOverrides(overrides string) { - // Format: ":,: + // Format: ":,:" if overrides == "" { return @@ -161,7 +160,7 @@ func GetBotGlobalLimit(token string, user *BotUserResponse) (uint, error) { return 0, errors.New("500 on gateway/bot") } - body, _ := ioutil.ReadAll(bot.Body) + body, _ := io.ReadAll(bot.Body) var s BotGatewayResponse @@ -200,7 +199,7 @@ func GetBotUser(token string) (*BotUserResponse, error) { return nil, errors.New("500 on users/@me") } - body, _ := ioutil.ReadAll(bot.Body) + body, _ := io.ReadAll(bot.Body) var s BotUserResponse @@ -254,7 +253,7 @@ func ProcessRequest(ctx context.Context, item *QueueItem) (*http.Response, error discordResp, err := doDiscordReq(ctx, req.URL.Path, req.Method, req.Body, req.Header.Clone(), req.URL.RawQuery) if err != nil { - if ctx.Err() == context.DeadlineExceeded { + if errors.Is(ctx.Err(), context.DeadlineExceeded) { res.WriteHeader(408) } else { res.WriteHeader(500) diff --git a/lib/queue.go b/lib/queue.go index 203383a..963275b 100644 --- a/lib/queue.go +++ b/lib/queue.go @@ -22,12 +22,15 @@ type QueueItem struct { } type QueueChannel struct { - ch chan *QueueItem - lastUsed time.Time + sync.RWMutex + ch chan *QueueItem + lastUsed time.Time + ratelimit *BucketRateLimit + lockerFun func(item *QueueItem) } type RequestQueue struct { - sync.RWMutex + sync.Mutex globalLockedUntil *int64 // bucket path hash as key queues map[uint64]*QueueChannel @@ -185,8 +188,9 @@ func (q *RequestQueue) getQueueChannel(path string, pathHash uint64) *QueueChann ch, ok := q.queues[pathHash] if !ok { ch = &QueueChannel{ - ch: make(chan *QueueItem, q.bufferSize), - lastUsed: t, + ch: make(chan *QueueItem, q.bufferSize), + lastUsed: t, + ratelimit: nil, } q.queues[pathHash] = ch // It's important that we only have 1 goroutine per channel @@ -197,54 +201,52 @@ func (q *RequestQueue) getQueueChannel(path string, pathHash uint64) *QueueChann return ch } -func parseHeaders(headers *http.Header, preferRetryAfter bool) (int64, int64, time.Duration, bool, error) { +func parseHeaders(headers *http.Header) (string, int64, int64, float64, float64, string, error) { if headers == nil { - return 0, 0, 0, false, errors.New("null headers") + return "", 0, 0, 0, 0, "", errors.New("null headers") } + bucket := headers.Get("x-ratelimit-bucket") limit := headers.Get("x-ratelimit-limit") remaining := headers.Get("x-ratelimit-remaining") + resetAt := headers.Get("x-ratelimit-reset") resetAfter := headers.Get("x-ratelimit-reset-after") - retryAfter := headers.Get("retry-after") - if resetAfter == "" || (preferRetryAfter && retryAfter != "") { - // Globals return no x-ratelimit-reset-after headers, shared ratelimits have a wrong reset-after - // this is the best option without parsing the body - resetAfter = headers.Get("retry-after") + scope := headers.Get("x-ratelimit-global") + if scope == "" { + scope = "route" } - isGlobal := headers.Get("x-ratelimit-global") == "true" - var resetParsed float64 - var reset time.Duration = 0 var err error - if resetAfter != "" { - resetParsed, err = strconv.ParseFloat(resetAfter, 64) - if err != nil { - return 0, 0, 0, false, err - } - // Convert to MS instead of seconds to preserve decimal precision - reset = time.Duration(int(resetParsed*1000)) * time.Millisecond + resetAfterParsed, err := strconv.ParseFloat(resetAfter, 64) + if err != nil { + return "", 0, 0, 0, 0, "", err } - if isGlobal { - return 0, 0, reset, isGlobal, nil + if scope == "global" { + return bucket, 0, 0, resetAfterParsed, 0, scope, nil } if limit == "" { - return 0, 0, reset, false, nil + return "", 0, 0, resetAfterParsed, 0, scope, nil } limitParsed, err := strconv.ParseInt(limit, 10, 32) if err != nil { - return 0, 0, 0, false, err + return "", 0, 0, 0, 0, "", err } remainingParsed, err := strconv.ParseInt(remaining, 10, 32) if err != nil { - return 0, 0, 0, false, err + return "", 0, 0, 0, 0, "", err } - return limitParsed, remainingParsed, reset, isGlobal, nil + resetAtParsed, err := strconv.ParseFloat(resetAt, 64) + if err != nil { + return "", 0, 0, 0, 0, "", err + } + + return bucket, remainingParsed, limitParsed, resetAfterParsed, resetAtParsed, scope, nil } func return404webhook(item *QueueItem) { @@ -254,10 +256,9 @@ func return404webhook(item *QueueItem) { _, err := res.Write([]byte(body)) if err != nil { item.errChan <- err - return + } else { + item.doneChan <- nil } - item.doneChan <- nil - } func return401(item *QueueItem) { @@ -282,106 +283,121 @@ func isInteraction(url string) bool { return false } -func (q *RequestQueue) subscribe(ch *QueueChannel, path string, pathHash uint64) { - // This function has 1 goroutine for each bucket path - // Locking here is not needed - - //Only used for logging - var prevRem int64 = 0 - var prevReset time.Duration = 0 +func (item *QueueItem) doRequest(ctx context.Context, q *RequestQueue, ch *QueueChannel, path string, pathHash uint64) { + resp, err := q.processor(ctx, item) + if err != nil { + item.errChan <- err + return + } - // Fail fast path for webhook 404s - var ret404 = false - for item := range ch.ch { - ctx := context.WithValue(item.Req.Context(), "identifier", q.identifier) - if ret404 { - return404webhook(item) - continue - } + bucket, remaining, limit, resetAfter, resetAt, scope, err := parseHeaders(&resp.Header) + if err != nil { + item.errChan <- err + return + } - if atomic.LoadInt64(q.isTokenInvalid) > 0 { - return401(item) - continue + // FIXME: Properly implement this like in hikari + if scope == "global" { + //Lock global + sw := atomic.CompareAndSwapInt64(q.globalLockedUntil, 0, int64(resetAt)) + if sw { + logger.WithFields(logrus.Fields{ + "until": int64(resetAt), + "resetAfter": resetAfter, + }).Warn("Global reached, locking") } + } - resp, err := q.processor(ctx, item) - if err != nil { - item.errChan <- err - continue + if bucket != "" { + if ch.ratelimit == nil { + ch.Lock() + if ch.ratelimit == nil { + logger.WithFields(logrus.Fields{"path": path, "bucket": bucket}).Info("New ratelimit") + ch.ratelimit = NewBucketRatelimit(remaining, limit, resetAt, resetAfter, path, q.user.Id) + } + ch.Unlock() + } else { + ch.ratelimit.Update(remaining, limit, resetAt, resetAfter) } + } - scope := resp.Header.Get("x-ratelimit-scope") + // FIXME: Consider handling special retry case for POST /users/@me/channels + + item.doneChan <- resp + + if resp.StatusCode == 429 && scope != "shared" { + logger.WithFields(logrus.Fields{ + "remaining": remaining, + "resetAfter": resetAfter, + "bucket": path, + "route": item.Req.URL.String(), + "method": item.Req.Method, + "scope": scope, + "pathHash": pathHash, + // TODO: Remove this when 429s are not a problem anymore + "discordBucket": resp.Header.Get("x-ratelimit-bucket"), + "ratelimitScope": resp.Header.Get("x-ratelimit-scope"), + }).Warn("Unexpected 429") + } - _, remaining, resetAfter, isGlobal, err := parseHeaders(&resp.Header, scope != "user") + if resp.StatusCode == 404 && strings.HasPrefix(path, "/webhooks/") && !isInteraction(item.Req.URL.String()) { + logger.WithFields(logrus.Fields{ + "bucket": path, + "route": item.Req.URL.String(), + "method": item.Req.Method, + }).Info("Setting fail fast 404 for webhook") - if isGlobal { - //Lock global - sw := atomic.CompareAndSwapInt64(q.globalLockedUntil, 0, time.Now().Add(resetAfter).UnixNano()) - if sw { - logger.WithFields(logrus.Fields{ - "until": time.Now().Add(resetAfter), - "resetAfter": resetAfter, - }).Warn("Global reached, locking") - } - } + ch.Lock() + ch.lockerFun = return404webhook + ch.Unlock() + } - if err != nil { - item.errChan <- err - continue + if resp.StatusCode == 401 && !isInteraction(item.Req.URL.String()) && q.queueType != NoAuth { + // Permanently lock this queue + logger.WithFields(logrus.Fields{ + "bucket": path, + "route": item.Req.URL.String(), + "method": item.Req.Method, + "identifier": q.identifier, + "status": resp.StatusCode, + }).Error("Received 401 during normal operation, assuming token is invalidated, locking bucket permanently") + + if EnvGet("DISABLE_401_LOCK", "false") != "true" { + atomic.StoreInt64(q.isTokenInvalid, 999) } - item.doneChan <- resp + } +} - if resp.StatusCode == 429 && scope != "shared" { - logger.WithFields(logrus.Fields{ - "prevRemaining": prevRem, - "prevResetAfter": prevReset, - "remaining": remaining, - "resetAfter": resetAfter, - "bucket": path, - "route": item.Req.URL.String(), - "method": item.Req.Method, - "isGlobal": isGlobal, - "pathHash": pathHash, - // TODO: Remove this when 429s are not a problem anymore - "discordBucket": resp.Header.Get("x-ratelimit-bucket"), - "ratelimitScope": resp.Header.Get("x-ratelimit-scope"), - }).Warn("Unexpected 429") - } +func (q *RequestQueue) subscribe(ch *QueueChannel, path string, pathHash uint64) { + // This function has 1 goroutine for each bucket path + // Locking here is not needed - if resp.StatusCode == 404 && strings.HasPrefix(path, "/webhooks/") && !isInteraction(item.Req.URL.String()) { - logger.WithFields(logrus.Fields{ - "bucket": path, - "route": item.Req.URL.String(), - "method": item.Req.Method, - }).Info("Setting fail fast 404 for webhook") - ret404 = true - } + for item := range ch.ch { + ctx := context.WithValue(item.Req.Context(), "identifier", q.identifier) - if resp.StatusCode == 401 && !isInteraction(item.Req.URL.String()) && q.queueType != NoAuth { - // Permanently lock this queue - logger.WithFields(logrus.Fields{ - "bucket": path, - "route": item.Req.URL.String(), - "method": item.Req.Method, - "identifier": q.identifier, - "status": resp.StatusCode, - }).Error("Received 401 during normal operation, assuming token is invalidated, locking bucket permanently") - - if EnvGet("DISABLE_401_LOCK", "false") != "true" { - atomic.StoreInt64(q.isTokenInvalid, 999) - } + if atomic.LoadInt64(q.isTokenInvalid) > 0 { + return401(item) + continue } - // Prevent reaction bucket from being stuck - if resp.StatusCode == 429 && scope == "shared" && (path == "/channels/!/messages/!/reactions/!modify" || path == "/channels/!/messages/!/reactions/!/!") { - prevRem, prevReset = remaining, resetAfter + if ch.lockerFun != nil { + ch.lockerFun(item) continue } - if remaining == 0 || resp.StatusCode == 429 { - duration := time.Until(time.Now().Add(resetAfter)) - time.Sleep(duration) + if ch.ratelimit != nil { + ch.ratelimit.Acquire(ctx) + + go item.doRequest(ctx, q, ch, path, pathHash) + + } else { + // We don't have the initial headers, so we do the requests sequentially, which should + // create and populate the bucket when it's known. + // If this is a route with no ratelimits, then we will simply execute them all sequentially, + // which should be fine + // + // TODO: Consider if its worth hard coding which routes will never have a bucket + item.doRequest(ctx, q, ch, path, pathHash) } - prevRem, prevReset = remaining, resetAfter } } diff --git a/lib/ratelimits.go b/lib/ratelimits.go new file mode 100644 index 0000000..b44721d --- /dev/null +++ b/lib/ratelimits.go @@ -0,0 +1,165 @@ +package lib + +import ( + "context" + "github.com/sirupsen/logrus" + "math" + "sync" + "time" +) + +// isClose checks if two durations are within `diff` seconds of difference +func isClose(a, b time.Duration, absTol float64) bool { + return math.Abs(a.Seconds()-b.Seconds()) <= absTol +} + +func calculateSlidingWindow(remaining, limit int64, resetAt, resetAfter float64) (time.Duration, time.Time) { + // slidePeriod = resetAfter / (limit - remaining) + slidePeriod := time.Duration((resetAfter/float64(limit-remaining))*1_000) * time.Millisecond + + // increaseAt = (resetAt - resetAfter) + slidePeriod + resetAtTime := time.Unix(0, int64(resetAt*1_000_000_000)) + resetAfterDuration := time.Duration(resetAfter*1_000) * time.Millisecond + increaseAt := resetAtTime.Add(-resetAfterDuration).Add(slidePeriod) + + return slidePeriod, increaseAt +} + +// BucketRateLimit is a sliding window ratelimit implementation +type BucketRateLimit struct { + userID string + bucket string + lock sync.Mutex + remaining int64 + limit int64 + period time.Duration + increaseAt time.Time + outOfSync bool +} + +func NewBucketRatelimit(remaining, limit int64, resetAt, resetAfter float64, bucket, userID string) *BucketRateLimit { + if remaining == limit { + // If we somehow get this case, then we cannot create a ratelimit from the info + return nil + } + + slidePeriod, increaseAt := calculateSlidingWindow(remaining, limit, resetAt, resetAfter) + + return &BucketRateLimit{ + bucket: bucket, + userID: userID, + remaining: remaining, + limit: limit, + period: slidePeriod, + increaseAt: increaseAt, + } +} + +// Note: this MUST be called from a locked state +func (b *BucketRateLimit) isRatelimited(now time.Time) bool { + // If we are out of sync, we shouldn't slide the window along, as we will be off due to + // network latency. + // The second part of this 'if' is to account for some cases where there can be a race + // condition and we receive rate limit updates out of order, and we cannot update `outOfSync` + if (now.After(b.increaseAt) || now.Equal(b.increaseAt)) && (!b.outOfSync || now.Sub(b.increaseAt) > b.period) { + // We can slide the window along + gain := int64(math.Floor((now.Sub(b.increaseAt).Seconds())/b.period.Seconds())) + 1 + nowRemaining := b.remaining + gain + + b.remaining = min(nowRemaining, b.limit) + + if b.remaining == b.limit { + // When a ratelimit resets, we will fall out of sync from the remote, so + // we want to prevent future sliding + b.increaseAt = now.Add(b.period) + b.outOfSync = true + } else { + b.increaseAt = b.increaseAt.Add(b.period * time.Duration(gain)) + } + } + + return b.remaining <= 0 +} + +// Acquire will request a slot from the ratelimit and sleep until there is one available +func (b *BucketRateLimit) Acquire(ctx context.Context) { + b.lock.Lock() + defer b.lock.Unlock() + + for { + now := time.Now() + if !b.isRatelimited(now) { + break + } + + sleepDuration := b.increaseAt.Sub(now) + if sleepDuration > 0 { + logger.WithFields(logrus.Fields{ + "bucket": b.bucket, + "user": b.userID, + "sleepDuration": sleepDuration, + }).Info("backing off to avoid hitting ratelimits") + + // FIXME: This doesn't work and idk why + select { + case <-ctx.Done(): + logger.Info("context cancelled") + return + case <-time.After(sleepDuration): + } + } + } + + b.remaining-- +} + +func (b *BucketRateLimit) Update(remaining, limit int64, resetAt, resetAfter float64) { + if remaining == limit { + // This should never happen, but just in case + return + } + + slidePeriod, increaseAt := calculateSlidingWindow(remaining, limit, resetAt, resetAfter) + + if increaseAt.Before(b.increaseAt) { + // Old ratelimit information, ignore + return + } + + b.lock.Lock() + defer b.lock.Unlock() + + if b.limit != limit { + if b.limit > limit { + logger.WithFields(logrus.Fields{ + "bucket": b.bucket, + "user": b.userID, + "newLimit": limit, + "oldLimit": b.limit, + }).Warn("Bucket decreased its limit. It is possible you will see a small increase in 429s") + } + + b.limit = limit + } + + // We want to update the slide period only, and only if: + // 1. The bucket is out of sync (ie, we reset the full window) + // 2. We receive the first usage of the bucket, which will always have the most accurate slide period + // 3. The slide periods differ too much. This is helpful if we diverged too much from the real one + // due to network latency, of if the bucket randomly changed + // Note: 0.3 and 0.5 are chosen arbitrarily after some testing + if b.outOfSync || remaining == limit-1 || !isClose(slidePeriod, b.period, 0.3) { + if !isClose(slidePeriod, b.period, 0.5) { + logger.WithFields(logrus.Fields{ + "bucket": b.bucket, + "user": b.userID, + "newSlidePeriod": slidePeriod, + "oldSlidePeriod": b.period, + }).Warn("Bucket greatly increased its slide period. It is possible you will see a small increase in 429s") + } + + b.outOfSync = false + b.period = slidePeriod + b.increaseAt = increaseAt + } +} diff --git a/main.go b/main.go index 2288d8b..3f3eaa4 100644 --- a/main.go +++ b/main.go @@ -108,7 +108,7 @@ func main() { go lib.StartMetrics(bindIp + ":" + port) } - done := make(chan os.Signal, 1) + done := make(chan os.Signal) signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) go func() { From 74368fabd336e7f4b6542b86e865eec7900ab210 Mon Sep 17 00:00:00 2001 From: davfsa Date: Sun, 22 Jun 2025 14:12:59 +0200 Subject: [PATCH 002/105] Raise ctx error when acquiring --- lib/queue.go | 5 ++++- lib/ratelimits.go | 5 +++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/queue.go b/lib/queue.go index 963275b..5f3fc2a 100644 --- a/lib/queue.go +++ b/lib/queue.go @@ -386,7 +386,10 @@ func (q *RequestQueue) subscribe(ch *QueueChannel, path string, pathHash uint64) } if ch.ratelimit != nil { - ch.ratelimit.Acquire(ctx) + if err := ch.ratelimit.Acquire(ctx); err != nil { + item.errChan <- err + continue + } go item.doRequest(ctx, q, ch, path, pathHash) diff --git a/lib/ratelimits.go b/lib/ratelimits.go index b44721d..9adce36 100644 --- a/lib/ratelimits.go +++ b/lib/ratelimits.go @@ -82,7 +82,7 @@ func (b *BucketRateLimit) isRatelimited(now time.Time) bool { } // Acquire will request a slot from the ratelimit and sleep until there is one available -func (b *BucketRateLimit) Acquire(ctx context.Context) { +func (b *BucketRateLimit) Acquire(ctx context.Context) error { b.lock.Lock() defer b.lock.Unlock() @@ -104,13 +104,14 @@ func (b *BucketRateLimit) Acquire(ctx context.Context) { select { case <-ctx.Done(): logger.Info("context cancelled") - return + return ctx.Err() case <-time.After(sleepDuration): } } } b.remaining-- + return nil } func (b *BucketRateLimit) Update(remaining, limit int64, resetAt, resetAfter float64) { From be1b21b608d09874739484b5e68c380d5c0fe1fa Mon Sep 17 00:00:00 2001 From: davfsa Date: Sun, 22 Jun 2025 14:16:36 +0200 Subject: [PATCH 003/105] Cleanup logs --- lib/queue.go | 1 - lib/ratelimits.go | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/queue.go b/lib/queue.go index 5f3fc2a..1a8d8c4 100644 --- a/lib/queue.go +++ b/lib/queue.go @@ -312,7 +312,6 @@ func (item *QueueItem) doRequest(ctx context.Context, q *RequestQueue, ch *Queue if ch.ratelimit == nil { ch.Lock() if ch.ratelimit == nil { - logger.WithFields(logrus.Fields{"path": path, "bucket": bucket}).Info("New ratelimit") ch.ratelimit = NewBucketRatelimit(remaining, limit, resetAt, resetAfter, path, q.user.Id) } ch.Unlock() diff --git a/lib/ratelimits.go b/lib/ratelimits.go index 9adce36..6edfb80 100644 --- a/lib/ratelimits.go +++ b/lib/ratelimits.go @@ -98,7 +98,7 @@ func (b *BucketRateLimit) Acquire(ctx context.Context) error { "bucket": b.bucket, "user": b.userID, "sleepDuration": sleepDuration, - }).Info("backing off to avoid hitting ratelimits") + }).Debug("backing off to avoid hitting ratelimits") // FIXME: This doesn't work and idk why select { From 8ab0a978eb48d3436ffec29af17c307baf1f77e3 Mon Sep 17 00:00:00 2001 From: davfsa Date: Sun, 22 Jun 2025 16:53:07 +0200 Subject: [PATCH 004/105] Add config option to disable concurrent requests --- lib/discord.go | 4 +++- lib/queue.go | 19 ++++++++++--------- lib/ratelimits.go | 7 +++++-- main.go | 3 ++- 4 files changed, 20 insertions(+), 13 deletions(-) diff --git a/lib/discord.go b/lib/discord.go index 89f0870..5cbee4d 100644 --- a/lib/discord.go +++ b/lib/discord.go @@ -22,6 +22,7 @@ var contextTimeout time.Duration var globalOverrideMap = make(map[string]uint) var disableRestLimitDetection = false +var allowConcurrentRequests = false type BotGatewayResponse struct { SessionStartLimit map[string]int `json:"session_start_limit"` @@ -110,7 +111,7 @@ func parseGlobalOverrides(overrides string) { } } -func ConfigureDiscordHTTPClient(ip string, timeout time.Duration, disableHttp2 bool, globalOverrides string, disableRestDetection bool) { +func ConfigureDiscordHTTPClient(ip string, timeout time.Duration, globalOverrides string, disableHttp2, disableRestDetection, allowConcurrent bool) { transport := createTransport(ip, disableHttp2) client = &http.Client{ Transport: transport, @@ -120,6 +121,7 @@ func ConfigureDiscordHTTPClient(ip string, timeout time.Duration, disableHttp2 b contextTimeout = timeout disableRestLimitDetection = disableRestDetection + allowConcurrentRequests = allowConcurrent parseGlobalOverrides(globalOverrides) } diff --git a/lib/queue.go b/lib/queue.go index 1a8d8c4..10e8520 100644 --- a/lib/queue.go +++ b/lib/queue.go @@ -389,17 +389,18 @@ func (q *RequestQueue) subscribe(ch *QueueChannel, path string, pathHash uint64) item.errChan <- err continue } + } - go item.doRequest(ctx, q, ch, path, pathHash) - - } else { - // We don't have the initial headers, so we do the requests sequentially, which should - // create and populate the bucket when it's known. - // If this is a route with no ratelimits, then we will simply execute them all sequentially, - // which should be fine - // - // TODO: Consider if its worth hard coding which routes will never have a bucket + // We don't have the initial headers, so we do the requests sequentially, which should + // create and populate the bucket when it's known, of it thats what the user wants + // If this is a route with no ratelimits, then we will simply execute them all sequentially, + // which should be fine + // + // TODO: Consider if its worth hard coding which routes will never have a bucket + if ch.ratelimit == nil || disableRestLimitDetection { item.doRequest(ctx, q, ch, path, pathHash) + } else { + go item.doRequest(ctx, q, ch, path, pathHash) } } } diff --git a/lib/ratelimits.go b/lib/ratelimits.go index 6edfb80..1e78611 100644 --- a/lib/ratelimits.go +++ b/lib/ratelimits.go @@ -103,7 +103,6 @@ func (b *BucketRateLimit) Acquire(ctx context.Context) error { // FIXME: This doesn't work and idk why select { case <-ctx.Done(): - logger.Info("context cancelled") return ctx.Err() case <-time.After(sleepDuration): } @@ -161,6 +160,10 @@ func (b *BucketRateLimit) Update(remaining, limit int64, resetAt, resetAfter flo b.outOfSync = false b.period = slidePeriod - b.increaseAt = increaseAt + + if increaseAt.After(b.increaseAt) { + // We only want to change this if we are lacking behind, as that can lead to 429s + b.increaseAt = increaseAt + } } } diff --git a/main.go b/main.go index 3f3eaa4..191f285 100644 --- a/main.go +++ b/main.go @@ -75,8 +75,9 @@ func main() { globalOverrides := lib.EnvGet("BOT_RATELIMIT_OVERRIDES", "") disableGlobalRatelimitDetection := lib.EnvGetBool("DISABLE_GLOBAL_RATELIMIT_DETECTION", false) + allowConcurrentRequests := lib.EnvGetBool("ALLOW_CONCURRENT_REQUESTS", false) - lib.ConfigureDiscordHTTPClient(outboundIp, time.Duration(timeout)*time.Millisecond, disableHttp2, globalOverrides, disableGlobalRatelimitDetection) + lib.ConfigureDiscordHTTPClient(outboundIp, time.Duration(timeout)*time.Millisecond, globalOverrides, disableHttp2, disableGlobalRatelimitDetection, allowConcurrentRequests) port := lib.EnvGet("PORT", "8080") bindIp := lib.EnvGet("BIND_IP", "0.0.0.0") From 61da1cd15c646cd1779c8245a5a68ad941dd5619 Mon Sep 17 00:00:00 2001 From: davfsa Date: Sun, 22 Jun 2025 16:57:09 +0200 Subject: [PATCH 005/105] Improve bucket logging --- lib/queue.go | 2 +- lib/ratelimits.go | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/queue.go b/lib/queue.go index 10e8520..811d5b9 100644 --- a/lib/queue.go +++ b/lib/queue.go @@ -312,7 +312,7 @@ func (item *QueueItem) doRequest(ctx context.Context, q *RequestQueue, ch *Queue if ch.ratelimit == nil { ch.Lock() if ch.ratelimit == nil { - ch.ratelimit = NewBucketRatelimit(remaining, limit, resetAt, resetAfter, path, q.user.Id) + ch.ratelimit = NewBucketRatelimit(remaining, limit, resetAt, resetAfter, bucket, path, q.user.Id) } ch.Unlock() } else { diff --git a/lib/ratelimits.go b/lib/ratelimits.go index 1e78611..fd527fe 100644 --- a/lib/ratelimits.go +++ b/lib/ratelimits.go @@ -28,6 +28,7 @@ func calculateSlidingWindow(remaining, limit int64, resetAt, resetAfter float64) // BucketRateLimit is a sliding window ratelimit implementation type BucketRateLimit struct { userID string + path string bucket string lock sync.Mutex remaining int64 @@ -37,7 +38,7 @@ type BucketRateLimit struct { outOfSync bool } -func NewBucketRatelimit(remaining, limit int64, resetAt, resetAfter float64, bucket, userID string) *BucketRateLimit { +func NewBucketRatelimit(remaining, limit int64, resetAt, resetAfter float64, bucket, path, userID string) *BucketRateLimit { if remaining == limit { // If we somehow get this case, then we cannot create a ratelimit from the info return nil @@ -47,6 +48,7 @@ func NewBucketRatelimit(remaining, limit int64, resetAt, resetAfter float64, buc return &BucketRateLimit{ bucket: bucket, + path: path, userID: userID, remaining: remaining, limit: limit, @@ -96,6 +98,7 @@ func (b *BucketRateLimit) Acquire(ctx context.Context) error { if sleepDuration > 0 { logger.WithFields(logrus.Fields{ "bucket": b.bucket, + "path": b.path, "user": b.userID, "sleepDuration": sleepDuration, }).Debug("backing off to avoid hitting ratelimits") @@ -133,6 +136,7 @@ func (b *BucketRateLimit) Update(remaining, limit int64, resetAt, resetAfter flo if b.limit > limit { logger.WithFields(logrus.Fields{ "bucket": b.bucket, + "path": b.path, "user": b.userID, "newLimit": limit, "oldLimit": b.limit, @@ -152,6 +156,7 @@ func (b *BucketRateLimit) Update(remaining, limit int64, resetAt, resetAfter flo if !isClose(slidePeriod, b.period, 0.5) { logger.WithFields(logrus.Fields{ "bucket": b.bucket, + "path": b.path, "user": b.userID, "newSlidePeriod": slidePeriod, "oldSlidePeriod": b.period, From 6ee19fe1534ffb484dbfc9a5ebcab83afc82493f Mon Sep 17 00:00:00 2001 From: davfsa Date: Sun, 22 Jun 2025 17:21:16 +0200 Subject: [PATCH 006/105] Improve timings --- lib/queue.go | 29 ++++++++++++++--------------- lib/ratelimits.go | 12 ++++-------- 2 files changed, 18 insertions(+), 23 deletions(-) diff --git a/lib/queue.go b/lib/queue.go index 811d5b9..dea4112 100644 --- a/lib/queue.go +++ b/lib/queue.go @@ -296,17 +296,8 @@ func (item *QueueItem) doRequest(ctx context.Context, q *RequestQueue, ch *Queue return } - // FIXME: Properly implement this like in hikari - if scope == "global" { - //Lock global - sw := atomic.CompareAndSwapInt64(q.globalLockedUntil, 0, int64(resetAt)) - if sw { - logger.WithFields(logrus.Fields{ - "until": int64(resetAt), - "resetAfter": resetAfter, - }).Warn("Global reached, locking") - } - } + // TODO: Consider handling special retry case for POST /users/@me/channels + item.doneChan <- resp if bucket != "" { if ch.ratelimit == nil { @@ -320,9 +311,17 @@ func (item *QueueItem) doRequest(ctx context.Context, q *RequestQueue, ch *Queue } } - // FIXME: Consider handling special retry case for POST /users/@me/channels - - item.doneChan <- resp + // FIXME: Properly implement this like in hikari + if scope == "global" { + //Lock global + sw := atomic.CompareAndSwapInt64(q.globalLockedUntil, 0, int64(resetAt)) + if sw { + logger.WithFields(logrus.Fields{ + "until": int64(resetAt), + "resetAfter": resetAfter, + }).Warn("Global reached, locking") + } + } if resp.StatusCode == 429 && scope != "shared" { logger.WithFields(logrus.Fields{ @@ -397,7 +396,7 @@ func (q *RequestQueue) subscribe(ch *QueueChannel, path string, pathHash uint64) // which should be fine // // TODO: Consider if its worth hard coding which routes will never have a bucket - if ch.ratelimit == nil || disableRestLimitDetection { + if ch.ratelimit == nil || !allowConcurrentRequests { item.doRequest(ctx, q, ch, path, pathHash) } else { go item.doRequest(ctx, q, ch, path, pathHash) diff --git a/lib/ratelimits.go b/lib/ratelimits.go index fd527fe..3883e89 100644 --- a/lib/ratelimits.go +++ b/lib/ratelimits.go @@ -151,9 +151,9 @@ func (b *BucketRateLimit) Update(remaining, limit int64, resetAt, resetAfter flo // 2. We receive the first usage of the bucket, which will always have the most accurate slide period // 3. The slide periods differ too much. This is helpful if we diverged too much from the real one // due to network latency, of if the bucket randomly changed - // Note: 0.3 and 0.5 are chosen arbitrarily after some testing - if b.outOfSync || remaining == limit-1 || !isClose(slidePeriod, b.period, 0.3) { - if !isClose(slidePeriod, b.period, 0.5) { + // Note: 0.5 and 0.7 are chosen arbitrarily after some testing + if b.outOfSync || remaining == limit-1 || !isClose(slidePeriod, b.period, 0.5) { + if !isClose(slidePeriod, b.period, 0.7) { logger.WithFields(logrus.Fields{ "bucket": b.bucket, "path": b.path, @@ -165,10 +165,6 @@ func (b *BucketRateLimit) Update(remaining, limit int64, resetAt, resetAfter flo b.outOfSync = false b.period = slidePeriod - - if increaseAt.After(b.increaseAt) { - // We only want to change this if we are lacking behind, as that can lead to 429s - b.increaseAt = increaseAt - } + b.increaseAt = increaseAt } } From 9ea43696949688829836cb48f4361162f03e9b63 Mon Sep 17 00:00:00 2001 From: davfsa Date: Sun, 22 Jun 2025 17:27:47 +0200 Subject: [PATCH 007/105] Fix handling of global ratelimits --- lib/queue.go | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/lib/queue.go b/lib/queue.go index dea4112..3882664 100644 --- a/lib/queue.go +++ b/lib/queue.go @@ -211,11 +211,18 @@ func parseHeaders(headers *http.Header) (string, int64, int64, float64, float64, remaining := headers.Get("x-ratelimit-remaining") resetAt := headers.Get("x-ratelimit-reset") resetAfter := headers.Get("x-ratelimit-reset-after") + retryAfter := headers.Get("retry-after") scope := headers.Get("x-ratelimit-global") if scope == "" { scope = "route" } + if resetAfter == "" || (scope == "global" && retryAfter != "") { + // Globals return no x-ratelimit-reset-after headers, shared ratelimits have a wrong reset-after + // this is the best option without parsing the body + resetAfter = headers.Get("retry-after") + } + var err error resetAfterParsed, err := strconv.ParseFloat(resetAfter, 64) @@ -291,6 +298,19 @@ func (item *QueueItem) doRequest(ctx context.Context, q *RequestQueue, ch *Queue } bucket, remaining, limit, resetAfter, resetAt, scope, err := parseHeaders(&resp.Header) + + if scope == "global" { + // Lock global + resetAfterDuration := time.Duration(resetAfter*1_000) * time.Millisecond + sw := atomic.CompareAndSwapInt64(q.globalLockedUntil, 0, time.Now().Add(resetAfterDuration).UnixNano()) + if sw { + logger.WithFields(logrus.Fields{ + "until": time.Now().Add(resetAfterDuration), + "resetAfter": resetAfterDuration, + }).Warn("Global reached, locking") + } + } + if err != nil { item.errChan <- err return @@ -311,18 +331,6 @@ func (item *QueueItem) doRequest(ctx context.Context, q *RequestQueue, ch *Queue } } - // FIXME: Properly implement this like in hikari - if scope == "global" { - //Lock global - sw := atomic.CompareAndSwapInt64(q.globalLockedUntil, 0, int64(resetAt)) - if sw { - logger.WithFields(logrus.Fields{ - "until": int64(resetAt), - "resetAfter": resetAfter, - }).Warn("Global reached, locking") - } - } - if resp.StatusCode == 429 && scope != "shared" { logger.WithFields(logrus.Fields{ "remaining": remaining, @@ -378,6 +386,10 @@ func (q *RequestQueue) subscribe(ch *QueueChannel, path string, pathHash uint64) continue } + if globalLockedUntil := atomic.LoadInt64(q.globalLockedUntil); globalLockedUntil > 0 { + time.Sleep(time.Duration(globalLockedUntil)) + } + if ch.lockerFun != nil { ch.lockerFun(item) continue From a7b162f7a791844076668b5b50820d0f0d673a27 Mon Sep 17 00:00:00 2001 From: davfsa Date: Sun, 22 Jun 2025 23:08:28 +0200 Subject: [PATCH 008/105] Finish off --- lib/discord.go | 5 +++-- lib/http.go | 6 +++--- lib/queue.go | 14 +++++++++++++- lib/ratelimits.go | 1 - main.go | 3 ++- 5 files changed, 21 insertions(+), 8 deletions(-) diff --git a/lib/discord.go b/lib/discord.go index 5cbee4d..5cb7ca2 100644 --- a/lib/discord.go +++ b/lib/discord.go @@ -1,6 +1,7 @@ package lib import ( + "bytes" "context" "crypto/tls" "encoding/json" @@ -213,7 +214,7 @@ func GetBotUser(token string) (*BotUserResponse, error) { return &s, nil } -func doDiscordReq(ctx context.Context, path string, method string, body io.ReadCloser, header http.Header, query string) (*http.Response, error) { +func doDiscordReq(ctx context.Context, path string, method string, body io.Reader, header http.Header, query string) (*http.Response, error) { discordReq, err := http.NewRequestWithContext(ctx, method, "https://discord.com"+path+"?"+query, body) if err != nil { return nil, err @@ -252,7 +253,7 @@ func ProcessRequest(ctx context.Context, item *QueueItem) (*http.Response, error ctx, cancel := context.WithTimeout(ctx, contextTimeout) defer cancel() - discordResp, err := doDiscordReq(ctx, req.URL.Path, req.Method, req.Body, req.Header.Clone(), req.URL.RawQuery) + discordResp, err := doDiscordReq(ctx, req.URL.Path, req.Method, bytes.NewReader(item.ReqBody), req.Header.Clone(), req.URL.RawQuery) if err != nil { if errors.Is(ctx.Err(), context.DeadlineExceeded) { diff --git a/lib/http.go b/lib/http.go index eaa1ecd..939f884 100644 --- a/lib/http.go +++ b/lib/http.go @@ -1,7 +1,7 @@ package lib import ( - "io/ioutil" + "io" "net/http" "strings" ) @@ -20,7 +20,7 @@ func copyHeader(dst, src http.Header) { func CopyResponseToResponseWriter(resp *http.Response, respWriter *http.ResponseWriter) error { writer := *respWriter - body, err := ioutil.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) if err != nil { writer.WriteHeader(500) _, _ = writer.Write([]byte(err.Error())) @@ -35,4 +35,4 @@ func CopyResponseToResponseWriter(resp *http.Response, respWriter *http.Response return err } return nil -} \ No newline at end of file +} diff --git a/lib/queue.go b/lib/queue.go index 3882664..a52c052 100644 --- a/lib/queue.go +++ b/lib/queue.go @@ -6,6 +6,7 @@ import ( "github.com/Clever/leakybucket" "github.com/Clever/leakybucket/memory" "github.com/sirupsen/logrus" + "io" "net/http" "strconv" "strings" @@ -17,6 +18,7 @@ import ( type QueueItem struct { Req *http.Request Res *http.ResponseWriter + ReqBody []byte doneChan chan *http.Response errChan chan error } @@ -171,7 +173,7 @@ func (q *RequestQueue) Queue(req *http.Request, res *http.ResponseWriter, path s doneChan := make(chan *http.Response) errChan := make(chan error) - safeSend(ch, &QueueItem{req, res, doneChan, errChan}) + safeSend(ch, &QueueItem{req, res, nil, doneChan, errChan}) select { case <-doneChan: @@ -395,6 +397,16 @@ func (q *RequestQueue) subscribe(ch *QueueChannel, path string, pathHash uint64) continue } + // This is unfortunate, but we need to read the body here so that the ctx gets closed properly + // when the client disconnects, which is very useful for cancelling `ratelimit.Acquire` early + // see: https://github.com/golang/go/issues/23262 + var err error + item.ReqBody, err = io.ReadAll(item.Req.Body) + if err != nil { + item.errChan <- err + continue + } + if ch.ratelimit != nil { if err := ch.ratelimit.Acquire(ctx); err != nil { item.errChan <- err diff --git a/lib/ratelimits.go b/lib/ratelimits.go index 3883e89..3b16b40 100644 --- a/lib/ratelimits.go +++ b/lib/ratelimits.go @@ -103,7 +103,6 @@ func (b *BucketRateLimit) Acquire(ctx context.Context) error { "sleepDuration": sleepDuration, }).Debug("backing off to avoid hitting ratelimits") - // FIXME: This doesn't work and idk why select { case <-ctx.Done(): return ctx.Err() diff --git a/main.go b/main.go index 191f285..bcf237f 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "github.com/germanoeich/nirn-proxy/lib" "github.com/hashicorp/memberlist" _ "github.com/joho/godotenv/autoload" @@ -113,7 +114,7 @@ func main() { signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) go func() { - if err := s.ListenAndServe(); err != nil && err != http.ErrServerClosed { + if err := s.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { logger.WithFields(logrus.Fields{"function": "http.ListenAndServe"}).Panic(err) } }() From bea4001009f37359930c0c6c216a8561225648de Mon Sep 17 00:00:00 2001 From: davfsa Date: Sun, 22 Jun 2025 23:10:33 +0200 Subject: [PATCH 009/105] Improve err log --- lib/queue_manager.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/queue_manager.go b/lib/queue_manager.go index f0f1ce4..21c511c 100644 --- a/lib/queue_manager.go +++ b/lib/queue_manager.go @@ -334,7 +334,10 @@ func (m *QueueManager) fulfillRequest(resp *http.ResponseWriter, req *http.Reque if err != nil { log := logEntry.WithField("function", "Queue") if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { - log.WithField("waitedFor", time.Since(reqStart)).Warn(err) + log.WithFields(logrus.Fields{ + "waitedFor": time.Since(reqStart), + "path": req.URL.Path, + }).Warn(err) } else { log.Error(err) } From b17e798343183f07c028649b54647172fae8402a Mon Sep 17 00:00:00 2001 From: davfsa Date: Sun, 22 Jun 2025 23:22:31 +0200 Subject: [PATCH 010/105] Update documentation on new option --- CONFIG.md | 7 +++++++ README.md | 57 ++++++++++++++++++++++++++++--------------------------- 2 files changed, 36 insertions(+), 28 deletions(-) diff --git a/CONFIG.md b/CONFIG.md index 6ad38c8..61b79c4 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -75,6 +75,13 @@ Default: false In the future, this will be the only possible behavior. +##### ALLOW_CONCURRENT_REQUESTS +Allows nirn to perform concurrent requests to Discord endpoints, instead of one at a time per queue. This might have the unintended side effect of a small increase in 429's if Discord updates buckets on the fly. + +If you do not care about throughput or do not make a lot of requests to the same endpoint that might take Discord a while to answer, then it would be fine to keep this off. + +Default: false + ## Unstable env vars Collection of env vars that may be removed at any time, mainly used for Discord introducing new behaviour on their edge api versions diff --git a/README.md b/README.md index f7d8ec9..b5c7261 100644 --- a/README.md +++ b/README.md @@ -24,26 +24,27 @@ The proxy sits between the client and discord. Instead of pointing to discord.co Configuration options are -| Variable | Value | Default | -|-----------------|---------------------------------------------|---------| -| LOG_LEVEL | panic, fatal, error, warn, info, debug, trace | info | -| PORT | number | 8080 | -| METRICS_PORT | number | 9000 | -| ENABLE_METRICS | boolean | true | -| ENABLE_PPROF | boolean | false | -| BUFFER_SIZE | number | 50 | -| OUTBOUND_IP | string | "" | -| BIND_IP | string | 0.0.0.0 | -| REQUEST_TIMEOUT | number (milliseconds) | 5000 | -| CLUSTER_PORT | number | 7946 | -| CLUSTER_MEMBERS | string list (comma separated) | "" | -| CLUSTER_DNS | string | "" | -| MAX_BEARER_COUNT| number | 1024 | -| DISABLE_HTTP_2 | bool | true | -| BOT_RATELIMIT_OVERRIDES | string list (comma separated) | "" | -| DISABLE_GLOBAL_RATELIMIT_DETECTION | boolean | false | - -Information on each config var can be found [here](https://github.com/germanoeich/nirn-proxy/blob/main/CONFIG.md) +| Variable | Value | Default | +|------------------------------------|-----------------------------------------------|---------| +| LOG_LEVEL | panic, fatal, error, warn, info, debug, trace | info | +| PORT | number | 8080 | +| METRICS_PORT | number | 9000 | +| ENABLE_METRICS | boolean | true | +| ENABLE_PPROF | boolean | false | +| BUFFER_SIZE | number | 50 | +| OUTBOUND_IP | string | "" | +| BIND_IP | string | 0.0.0.0 | +| REQUEST_TIMEOUT | number (milliseconds) | 5000 | +| CLUSTER_PORT | number | 7946 | +| CLUSTER_MEMBERS | string list (comma separated) | "" | +| CLUSTER_DNS | string | "" | +| MAX_BEARER_COUNT | number | 1024 | +| DISABLE_HTTP_2 | bool | true | +| BOT_RATELIMIT_OVERRIDES | string list (comma separated) | "" | +| DISABLE_GLOBAL_RATELIMIT_DETECTION | boolean | false | +| ALLOW_CONCURRENT_REQUESTS | boolean | false | + +Information on each config var can be found [here](CONFIG.md) .env files are loaded if present @@ -101,14 +102,14 @@ This will vary depending on your usage, how many unique routes you see, etc. For ### Metrics / Health -| Key | Labels | Description | -|------------------------------------|----------------------------------------|------------------------------------------------------------| -|nirn_proxy_error | none | Counter for errors | -|nirn_proxy_requests | method, status, route, clientId | Histogram that keeps track of all request metrics | -|nirn_proxy_open_connections | route, method | Gauge for open client connections with the proxy | -|nirn_proxy_requests_routed_sent | none | Counter for requests routed to other nodes | -|nirn_proxy_requests_routed_received | none | Counter for requests received from other nodes | -|nirn_proxy_requests_routed_error | none | Counter for requests routed that failed | +| Key | Labels | Description | +|-------------------------------------|---------------------------------|---------------------------------------------------| +| nirn_proxy_error | none | Counter for errors | +| nirn_proxy_requests | method, status, route, clientId | Histogram that keeps track of all request metrics | +| nirn_proxy_open_connections | route, method | Gauge for open client connections with the proxy | +| nirn_proxy_requests_routed_sent | none | Counter for requests routed to other nodes | +| nirn_proxy_requests_routed_received | none | Counter for requests received from other nodes | +| nirn_proxy_requests_routed_error | none | Counter for requests routed that failed | Note: 429s can produce two status: 429 Too Many Requests or 429 Shared. The latter is only produced for requests that return with the x-ratelimit-scope header set to "shared", which means they don't count towards the cloudflare firewall limit and thus should not be used for alerts, etc. From bde8d1833a52f223795a32bcba3e867fb6d6b5d5 Mon Sep 17 00:00:00 2001 From: davfsa Date: Sun, 22 Jun 2025 23:27:44 +0200 Subject: [PATCH 011/105] Use mutex instead of RWMutex for queue channels --- lib/queue.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/queue.go b/lib/queue.go index a52c052..e282d1c 100644 --- a/lib/queue.go +++ b/lib/queue.go @@ -24,7 +24,7 @@ type QueueItem struct { } type QueueChannel struct { - sync.RWMutex + sync.Mutex ch chan *QueueItem lastUsed time.Time ratelimit *BucketRateLimit From e597d93ca0025744ad5244adae58e6007b995b20 Mon Sep 17 00:00:00 2001 From: davfsa Date: Mon, 23 Jun 2025 01:04:11 +0200 Subject: [PATCH 012/105] Adjust lock when doing a bucket update --- lib/ratelimits.go | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/ratelimits.go b/lib/ratelimits.go index 3b16b40..48a591d 100644 --- a/lib/ratelimits.go +++ b/lib/ratelimits.go @@ -64,6 +64,9 @@ func (b *BucketRateLimit) isRatelimited(now time.Time) bool { // The second part of this 'if' is to account for some cases where there can be a race // condition and we receive rate limit updates out of order, and we cannot update `outOfSync` if (now.After(b.increaseAt) || now.Equal(b.increaseAt)) && (!b.outOfSync || now.Sub(b.increaseAt) > b.period) { + if b.outOfSync && now.Sub(b.increaseAt) > b.period { + logrus.Info("healing") + } // We can slide the window along gain := int64(math.Floor((now.Sub(b.increaseAt).Seconds())/b.period.Seconds())) + 1 nowRemaining := b.remaining + gain @@ -101,7 +104,7 @@ func (b *BucketRateLimit) Acquire(ctx context.Context) error { "path": b.path, "user": b.userID, "sleepDuration": sleepDuration, - }).Debug("backing off to avoid hitting ratelimits") + }).Info("backing off to avoid hitting ratelimits") select { case <-ctx.Done(): @@ -123,14 +126,14 @@ func (b *BucketRateLimit) Update(remaining, limit int64, resetAt, resetAfter flo slidePeriod, increaseAt := calculateSlidingWindow(remaining, limit, resetAt, resetAfter) + b.lock.Lock() + defer b.lock.Unlock() + if increaseAt.Before(b.increaseAt) { // Old ratelimit information, ignore return } - b.lock.Lock() - defer b.lock.Unlock() - if b.limit != limit { if b.limit > limit { logger.WithFields(logrus.Fields{ From 570b8c25c3d1eb69646ef243bc710c90314e32cb Mon Sep 17 00:00:00 2001 From: davfsa Date: Mon, 23 Jun 2025 01:05:41 +0200 Subject: [PATCH 013/105] Revert incorrect log change --- lib/ratelimits.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ratelimits.go b/lib/ratelimits.go index 48a591d..62402b2 100644 --- a/lib/ratelimits.go +++ b/lib/ratelimits.go @@ -104,7 +104,7 @@ func (b *BucketRateLimit) Acquire(ctx context.Context) error { "path": b.path, "user": b.userID, "sleepDuration": sleepDuration, - }).Info("backing off to avoid hitting ratelimits") + }).Debug("backing off to avoid hitting ratelimits") select { case <-ctx.Done(): From f899d382cb632e1cdca7a1d69c244536f17f4503 Mon Sep 17 00:00:00 2001 From: davfsa Date: Mon, 23 Jun 2025 01:07:51 +0200 Subject: [PATCH 014/105] Remove healing log --- lib/ratelimits.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/lib/ratelimits.go b/lib/ratelimits.go index 62402b2..c71280d 100644 --- a/lib/ratelimits.go +++ b/lib/ratelimits.go @@ -64,9 +64,6 @@ func (b *BucketRateLimit) isRatelimited(now time.Time) bool { // The second part of this 'if' is to account for some cases where there can be a race // condition and we receive rate limit updates out of order, and we cannot update `outOfSync` if (now.After(b.increaseAt) || now.Equal(b.increaseAt)) && (!b.outOfSync || now.Sub(b.increaseAt) > b.period) { - if b.outOfSync && now.Sub(b.increaseAt) > b.period { - logrus.Info("healing") - } // We can slide the window along gain := int64(math.Floor((now.Sub(b.increaseAt).Seconds())/b.period.Seconds())) + 1 nowRemaining := b.remaining + gain From 9c6bc9bfec97bff3d8533a1f7d273b4d167b791d Mon Sep 17 00:00:00 2001 From: davfsa Date: Mon, 23 Jun 2025 11:20:06 +0200 Subject: [PATCH 015/105] Improve parsing of headers --- lib/queue.go | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/queue.go b/lib/queue.go index e282d1c..c9b75fd 100644 --- a/lib/queue.go +++ b/lib/queue.go @@ -219,7 +219,7 @@ func parseHeaders(headers *http.Header) (string, int64, int64, float64, float64, scope = "route" } - if resetAfter == "" || (scope == "global" && retryAfter != "") { + if resetAfter == "" || (scope != "user" && retryAfter != "") { // Globals return no x-ratelimit-reset-after headers, shared ratelimits have a wrong reset-after // this is the best option without parsing the body resetAfter = headers.Get("retry-after") @@ -227,9 +227,12 @@ func parseHeaders(headers *http.Header) (string, int64, int64, float64, float64, var err error - resetAfterParsed, err := strconv.ParseFloat(resetAfter, 64) - if err != nil { - return "", 0, 0, 0, 0, "", err + var resetAfterParsed float64 = 0 + if resetAfter == "" { + resetAfterParsed, err = strconv.ParseFloat(resetAfter, 64) + if err != nil { + return "", 0, 0, 0, 0, "", err + } } if scope == "global" { From 896c29790a2abea64d288588828a8fa4a492d651 Mon Sep 17 00:00:00 2001 From: davfsa Date: Tue, 24 Jun 2025 12:14:32 +0200 Subject: [PATCH 016/105] Add some logging information --- lib/queue.go | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/lib/queue.go b/lib/queue.go index c9b75fd..9ae88c8 100644 --- a/lib/queue.go +++ b/lib/queue.go @@ -203,7 +203,7 @@ func (q *RequestQueue) getQueueChannel(path string, pathHash uint64) *QueueChann return ch } -func parseHeaders(headers *http.Header) (string, int64, int64, float64, float64, string, error) { +func parseHeaders(path string, headers *http.Header) (string, int64, int64, float64, float64, string, error) { if headers == nil { return "", 0, 0, 0, 0, "", errors.New("null headers") } @@ -215,6 +215,18 @@ func parseHeaders(headers *http.Header) (string, int64, int64, float64, float64, resetAfter := headers.Get("x-ratelimit-reset-after") retryAfter := headers.Get("retry-after") scope := headers.Get("x-ratelimit-global") + + logger.WithFields(logrus.Fields{ + "path": path, + "bucket": bucket, + "limit": limit, + "remaining": remaining, + "resetAt": resetAt, + "resetAfter": resetAfter, + "retryAfter": retryAfter, + "scope": scope, + }).Info("Parsed headers") + if scope == "" { scope = "route" } @@ -302,7 +314,7 @@ func (item *QueueItem) doRequest(ctx context.Context, q *RequestQueue, ch *Queue return } - bucket, remaining, limit, resetAfter, resetAt, scope, err := parseHeaders(&resp.Header) + bucket, remaining, limit, resetAfter, resetAt, scope, err := parseHeaders(path, &resp.Header) if scope == "global" { // Lock global From 6f7f69b79a667c579cea21143b4d44b118e7f214 Mon Sep 17 00:00:00 2001 From: davfsa Date: Tue, 24 Jun 2025 12:43:01 +0200 Subject: [PATCH 017/105] Fix incorrect negation --- lib/queue.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/queue.go b/lib/queue.go index 9ae88c8..4313601 100644 --- a/lib/queue.go +++ b/lib/queue.go @@ -240,7 +240,7 @@ func parseHeaders(path string, headers *http.Header) (string, int64, int64, floa var err error var resetAfterParsed float64 = 0 - if resetAfter == "" { + if resetAfter != "" { resetAfterParsed, err = strconv.ParseFloat(resetAfter, 64) if err != nil { return "", 0, 0, 0, 0, "", err From 98faf762950ce0ee73420986b21349118adf45b7 Mon Sep 17 00:00:00 2001 From: davfsa Date: Tue, 24 Jun 2025 16:12:49 +0200 Subject: [PATCH 018/105] Use identifier instead of user ID --- lib/queue.go | 2 +- lib/ratelimits.go | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/lib/queue.go b/lib/queue.go index 4313601..fa8d908 100644 --- a/lib/queue.go +++ b/lib/queue.go @@ -340,7 +340,7 @@ func (item *QueueItem) doRequest(ctx context.Context, q *RequestQueue, ch *Queue if ch.ratelimit == nil { ch.Lock() if ch.ratelimit == nil { - ch.ratelimit = NewBucketRatelimit(remaining, limit, resetAt, resetAfter, bucket, path, q.user.Id) + ch.ratelimit = NewBucketRatelimit(remaining, limit, resetAt, resetAfter, bucket, path, q.identifier) } ch.Unlock() } else { diff --git a/lib/ratelimits.go b/lib/ratelimits.go index c71280d..1a8d3a1 100644 --- a/lib/ratelimits.go +++ b/lib/ratelimits.go @@ -27,7 +27,7 @@ func calculateSlidingWindow(remaining, limit int64, resetAt, resetAfter float64) // BucketRateLimit is a sliding window ratelimit implementation type BucketRateLimit struct { - userID string + identifier string path string bucket string lock sync.Mutex @@ -38,7 +38,7 @@ type BucketRateLimit struct { outOfSync bool } -func NewBucketRatelimit(remaining, limit int64, resetAt, resetAfter float64, bucket, path, userID string) *BucketRateLimit { +func NewBucketRatelimit(remaining, limit int64, resetAt, resetAfter float64, bucket, path, identifier string) *BucketRateLimit { if remaining == limit { // If we somehow get this case, then we cannot create a ratelimit from the info return nil @@ -49,7 +49,7 @@ func NewBucketRatelimit(remaining, limit int64, resetAt, resetAfter float64, buc return &BucketRateLimit{ bucket: bucket, path: path, - userID: userID, + identifier: identifier, remaining: remaining, limit: limit, period: slidePeriod, @@ -99,7 +99,7 @@ func (b *BucketRateLimit) Acquire(ctx context.Context) error { logger.WithFields(logrus.Fields{ "bucket": b.bucket, "path": b.path, - "user": b.userID, + "identifier": b.identifier, "sleepDuration": sleepDuration, }).Debug("backing off to avoid hitting ratelimits") @@ -134,11 +134,11 @@ func (b *BucketRateLimit) Update(remaining, limit int64, resetAt, resetAfter flo if b.limit != limit { if b.limit > limit { logger.WithFields(logrus.Fields{ - "bucket": b.bucket, - "path": b.path, - "user": b.userID, - "newLimit": limit, - "oldLimit": b.limit, + "bucket": b.bucket, + "path": b.path, + "identifier": b.identifier, + "newLimit": limit, + "oldLimit": b.limit, }).Warn("Bucket decreased its limit. It is possible you will see a small increase in 429s") } @@ -156,7 +156,7 @@ func (b *BucketRateLimit) Update(remaining, limit int64, resetAt, resetAfter flo logger.WithFields(logrus.Fields{ "bucket": b.bucket, "path": b.path, - "user": b.userID, + "identifier": b.identifier, "newSlidePeriod": slidePeriod, "oldSlidePeriod": b.period, }).Warn("Bucket greatly increased its slide period. It is possible you will see a small increase in 429s") From d544584e70772b6ee37e5507790e767f75d7f28a Mon Sep 17 00:00:00 2001 From: davfsa Date: Tue, 24 Jun 2025 16:13:02 +0200 Subject: [PATCH 019/105] Remove extra logging --- lib/queue.go | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/lib/queue.go b/lib/queue.go index fa8d908..8b05c18 100644 --- a/lib/queue.go +++ b/lib/queue.go @@ -216,17 +216,6 @@ func parseHeaders(path string, headers *http.Header) (string, int64, int64, floa retryAfter := headers.Get("retry-after") scope := headers.Get("x-ratelimit-global") - logger.WithFields(logrus.Fields{ - "path": path, - "bucket": bucket, - "limit": limit, - "remaining": remaining, - "resetAt": resetAt, - "resetAfter": resetAfter, - "retryAfter": retryAfter, - "scope": scope, - }).Info("Parsed headers") - if scope == "" { scope = "route" } From 4624c7e28d29512e70c7182d86a6256d821eada4 Mon Sep 17 00:00:00 2001 From: davfsa Date: Tue, 24 Jun 2025 16:52:53 +0200 Subject: [PATCH 020/105] Get scope from correct header --- lib/queue.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/queue.go b/lib/queue.go index 8b05c18..b183547 100644 --- a/lib/queue.go +++ b/lib/queue.go @@ -214,7 +214,7 @@ func parseHeaders(path string, headers *http.Header) (string, int64, int64, floa resetAt := headers.Get("x-ratelimit-reset") resetAfter := headers.Get("x-ratelimit-reset-after") retryAfter := headers.Get("retry-after") - scope := headers.Get("x-ratelimit-global") + scope := headers.Get("x-ratelimit-scope") if scope == "" { scope = "route" @@ -347,8 +347,8 @@ func (item *QueueItem) doRequest(ctx context.Context, q *RequestQueue, ch *Queue "scope": scope, "pathHash": pathHash, // TODO: Remove this when 429s are not a problem anymore - "discordBucket": resp.Header.Get("x-ratelimit-bucket"), - "ratelimitScope": resp.Header.Get("x-ratelimit-scope"), + "discordBucket": bucket, + "ratelimitScope": scope, }).Warn("Unexpected 429") } From 04bf222a612a475203ab84a1a62f2eaf5c72fb0a Mon Sep 17 00:00:00 2001 From: davfsa Date: Tue, 24 Jun 2025 18:59:42 +0200 Subject: [PATCH 021/105] Lower remaining too if limit is lowered --- lib/ratelimits.go | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/ratelimits.go b/lib/ratelimits.go index 1a8d3a1..853a4b2 100644 --- a/lib/ratelimits.go +++ b/lib/ratelimits.go @@ -143,6 +143,7 @@ func (b *BucketRateLimit) Update(remaining, limit int64, resetAt, resetAfter flo } b.limit = limit + b.remaining = min(b.remaining, b.limit) } // We want to update the slide period only, and only if: From e50b39dd6cae580f9f694bfaac1522cbff621254 Mon Sep 17 00:00:00 2001 From: davfsa Date: Wed, 25 Jun 2025 11:25:07 +0200 Subject: [PATCH 022/105] Remove unused argument --- lib/queue.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/queue.go b/lib/queue.go index b183547..284b337 100644 --- a/lib/queue.go +++ b/lib/queue.go @@ -203,7 +203,7 @@ func (q *RequestQueue) getQueueChannel(path string, pathHash uint64) *QueueChann return ch } -func parseHeaders(path string, headers *http.Header) (string, int64, int64, float64, float64, string, error) { +func parseHeaders(headers *http.Header) (string, int64, int64, float64, float64, string, error) { if headers == nil { return "", 0, 0, 0, 0, "", errors.New("null headers") } @@ -303,7 +303,7 @@ func (item *QueueItem) doRequest(ctx context.Context, q *RequestQueue, ch *Queue return } - bucket, remaining, limit, resetAfter, resetAt, scope, err := parseHeaders(path, &resp.Header) + bucket, remaining, limit, resetAfter, resetAt, scope, err := parseHeaders(&resp.Header) if scope == "global" { // Lock global From 0699f165c0ea214e94bf67f16e8741b4220da135 Mon Sep 17 00:00:00 2001 From: davfsa Date: Wed, 9 Jul 2025 09:20:38 +0200 Subject: [PATCH 023/105] Improve ratelimit handling when starting already hitting ratelimits or starting in the middle of a window This can help in cases where NIRN is restarted and it loses all ratelimiting information --- lib/ratelimits.go | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/lib/ratelimits.go b/lib/ratelimits.go index 853a4b2..1ec2900 100644 --- a/lib/ratelimits.go +++ b/lib/ratelimits.go @@ -15,7 +15,7 @@ func isClose(a, b time.Duration, absTol float64) bool { func calculateSlidingWindow(remaining, limit int64, resetAt, resetAfter float64) (time.Duration, time.Time) { // slidePeriod = resetAfter / (limit - remaining) - slidePeriod := time.Duration((resetAfter/float64(limit-remaining))*1_000) * time.Millisecond + slidePeriod := time.Duration(math.Ceil((resetAfter/float64(limit-remaining))*1_000)) * time.Millisecond // increaseAt = (resetAt - resetAfter) + slidePeriod resetAtTime := time.Unix(0, int64(resetAt*1_000_000_000)) @@ -149,11 +149,9 @@ func (b *BucketRateLimit) Update(remaining, limit int64, resetAt, resetAfter flo // We want to update the slide period only, and only if: // 1. The bucket is out of sync (ie, we reset the full window) // 2. We receive the first usage of the bucket, which will always have the most accurate slide period - // 3. The slide periods differ too much. This is helpful if we diverged too much from the real one - // due to network latency, of if the bucket randomly changed - // Note: 0.5 and 0.7 are chosen arbitrarily after some testing - if b.outOfSync || remaining == limit-1 || !isClose(slidePeriod, b.period, 0.5) { - if !isClose(slidePeriod, b.period, 0.7) { + // 3. The slide period increased + if b.outOfSync || remaining == limit-1 || slidePeriod > b.period { + if !isClose(slidePeriod, b.period, 0.3) { logger.WithFields(logrus.Fields{ "bucket": b.bucket, "path": b.path, From 7e4310fc840f3ef0f677c6dbcc785d6077002b24 Mon Sep 17 00:00:00 2001 From: davfsa Date: Wed, 9 Jul 2025 10:19:00 +0200 Subject: [PATCH 024/105] Re-add case for slide period differing too much with adjusted limits --- lib/ratelimits.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/ratelimits.go b/lib/ratelimits.go index 1ec2900..66a37bf 100644 --- a/lib/ratelimits.go +++ b/lib/ratelimits.go @@ -150,8 +150,10 @@ func (b *BucketRateLimit) Update(remaining, limit int64, resetAt, resetAfter flo // 1. The bucket is out of sync (ie, we reset the full window) // 2. We receive the first usage of the bucket, which will always have the most accurate slide period // 3. The slide period increased - if b.outOfSync || remaining == limit-1 || slidePeriod > b.period { - if !isClose(slidePeriod, b.period, 0.3) { + // 4. The slide period greatly changed + // Note: 0.3 and 0.5 are chosen arbitrarily after some testing + if b.outOfSync || remaining == limit-1 || slidePeriod > b.period || !isClose(slidePeriod, b.period, 0.3) { + if !isClose(slidePeriod, b.period, 0.5) { logger.WithFields(logrus.Fields{ "bucket": b.bucket, "path": b.path, From 45afabd1b2cd0b080285ee60d5cb08459a0a44a9 Mon Sep 17 00:00:00 2001 From: davfsa Date: Wed, 9 Jul 2025 10:21:58 +0200 Subject: [PATCH 025/105] Update logging wording --- lib/ratelimits.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ratelimits.go b/lib/ratelimits.go index 66a37bf..c422f74 100644 --- a/lib/ratelimits.go +++ b/lib/ratelimits.go @@ -160,7 +160,7 @@ func (b *BucketRateLimit) Update(remaining, limit int64, resetAt, resetAfter flo "identifier": b.identifier, "newSlidePeriod": slidePeriod, "oldSlidePeriod": b.period, - }).Warn("Bucket greatly increased its slide period. It is possible you will see a small increase in 429s") + }).Warn("Bucket greatly changed its slide period. It is possible you will see a small increase in 429s") } b.outOfSync = false From 944c2d665090b39b533b445fefc62015060e2d97 Mon Sep 17 00:00:00 2001 From: davfsa Date: Thu, 10 Jul 2025 12:06:23 +0200 Subject: [PATCH 026/105] fix: add support for routes that have a fixed window Signed-off-by: davfsa --- lib/queue.go | 8 +++++- lib/ratelimits.go | 66 +++++++++++++++++++++++++++-------------------- 2 files changed, 45 insertions(+), 29 deletions(-) diff --git a/lib/queue.go b/lib/queue.go index 284b337..f50edb1 100644 --- a/lib/queue.go +++ b/lib/queue.go @@ -329,7 +329,13 @@ func (item *QueueItem) doRequest(ctx context.Context, q *RequestQueue, ch *Queue if ch.ratelimit == nil { ch.Lock() if ch.ratelimit == nil { - ch.ratelimit = NewBucketRatelimit(remaining, limit, resetAt, resetAfter, bucket, path, q.identifier) + // This is insanely unfortunate, but this specific route has fixed window instead of a sliding one + fixedWindow := false + if strings.HasSuffix(path, "/members/!/roles/!") { + fixedWindow = true + } + + ch.ratelimit = NewBucketRatelimit(remaining, limit, resetAt, resetAfter, bucket, path, q.identifier, fixedWindow) } ch.Unlock() } else { diff --git a/lib/ratelimits.go b/lib/ratelimits.go index c422f74..90e04a1 100644 --- a/lib/ratelimits.go +++ b/lib/ratelimits.go @@ -27,18 +27,19 @@ func calculateSlidingWindow(remaining, limit int64, resetAt, resetAfter float64) // BucketRateLimit is a sliding window ratelimit implementation type BucketRateLimit struct { - identifier string - path string - bucket string - lock sync.Mutex - remaining int64 - limit int64 - period time.Duration - increaseAt time.Time - outOfSync bool + identifier string + path string + bucket string + lock sync.Mutex + remaining int64 + limit int64 + period time.Duration + increaseAt time.Time + outOfSync bool + fixedWindow bool } -func NewBucketRatelimit(remaining, limit int64, resetAt, resetAfter float64, bucket, path, identifier string) *BucketRateLimit { +func NewBucketRatelimit(remaining, limit int64, resetAt, resetAfter float64, bucket, path, identifier string, fixedWindow bool) *BucketRateLimit { if remaining == limit { // If we somehow get this case, then we cannot create a ratelimit from the info return nil @@ -47,13 +48,14 @@ func NewBucketRatelimit(remaining, limit int64, resetAt, resetAfter float64, buc slidePeriod, increaseAt := calculateSlidingWindow(remaining, limit, resetAt, resetAfter) return &BucketRateLimit{ - bucket: bucket, - path: path, - identifier: identifier, - remaining: remaining, - limit: limit, - period: slidePeriod, - increaseAt: increaseAt, + bucket: bucket, + path: path, + identifier: identifier, + remaining: remaining, + limit: limit, + period: slidePeriod, + increaseAt: increaseAt, + fixedWindow: fixedWindow, } } @@ -64,19 +66,27 @@ func (b *BucketRateLimit) isRatelimited(now time.Time) bool { // The second part of this 'if' is to account for some cases where there can be a race // condition and we receive rate limit updates out of order, and we cannot update `outOfSync` if (now.After(b.increaseAt) || now.Equal(b.increaseAt)) && (!b.outOfSync || now.Sub(b.increaseAt) > b.period) { - // We can slide the window along - gain := int64(math.Floor((now.Sub(b.increaseAt).Seconds())/b.period.Seconds())) + 1 - nowRemaining := b.remaining + gain - - b.remaining = min(nowRemaining, b.limit) - - if b.remaining == b.limit { - // When a ratelimit resets, we will fall out of sync from the remote, so - // we want to prevent future sliding - b.increaseAt = now.Add(b.period) + if b.fixedWindow { + // Fixed windows just reset the remaining back to the limit + b.remaining = b.limit b.outOfSync = true + b.increaseAt = now.Add(b.period) + } else { - b.increaseAt = b.increaseAt.Add(b.period * time.Duration(gain)) + // We can slide the window along + gain := int64(math.Floor((now.Sub(b.increaseAt).Seconds())/b.period.Seconds())) + 1 + nowRemaining := b.remaining + gain + + b.remaining = min(nowRemaining, b.limit) + + if b.remaining == b.limit { + // When a ratelimit resets, we will fall out of sync from the remote, so + // we want to prevent future sliding + b.increaseAt = now.Add(b.period) + b.outOfSync = true + } else { + b.increaseAt = b.increaseAt.Add(b.period * time.Duration(gain)) + } } } From 869bbc3fe3352efe86e4428107004e3bf0acd864 Mon Sep 17 00:00:00 2001 From: davfsa Date: Mon, 25 Aug 2025 01:39:19 +0200 Subject: [PATCH 027/105] feat: improve detection of fixed window ratelimits Signed-off-by: davfsa --- lib/queue.go | 8 +------- lib/ratelimits.go | 19 ++++++++++++++++--- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/lib/queue.go b/lib/queue.go index f50edb1..284b337 100644 --- a/lib/queue.go +++ b/lib/queue.go @@ -329,13 +329,7 @@ func (item *QueueItem) doRequest(ctx context.Context, q *RequestQueue, ch *Queue if ch.ratelimit == nil { ch.Lock() if ch.ratelimit == nil { - // This is insanely unfortunate, but this specific route has fixed window instead of a sliding one - fixedWindow := false - if strings.HasSuffix(path, "/members/!/roles/!") { - fixedWindow = true - } - - ch.ratelimit = NewBucketRatelimit(remaining, limit, resetAt, resetAfter, bucket, path, q.identifier, fixedWindow) + ch.ratelimit = NewBucketRatelimit(remaining, limit, resetAt, resetAfter, bucket, path, q.identifier) } ch.Unlock() } else { diff --git a/lib/ratelimits.go b/lib/ratelimits.go index 90e04a1..d5e9aa2 100644 --- a/lib/ratelimits.go +++ b/lib/ratelimits.go @@ -2,10 +2,11 @@ package lib import ( "context" - "github.com/sirupsen/logrus" "math" "sync" "time" + + "github.com/sirupsen/logrus" ) // isClose checks if two durations are within `diff` seconds of difference @@ -35,11 +36,12 @@ type BucketRateLimit struct { limit int64 period time.Duration increaseAt time.Time + resetAt time.Time outOfSync bool fixedWindow bool } -func NewBucketRatelimit(remaining, limit int64, resetAt, resetAfter float64, bucket, path, identifier string, fixedWindow bool) *BucketRateLimit { +func NewBucketRatelimit(remaining, limit int64, resetAt, resetAfter float64, bucket, path, identifier string) *BucketRateLimit { if remaining == limit { // If we somehow get this case, then we cannot create a ratelimit from the info return nil @@ -52,10 +54,11 @@ func NewBucketRatelimit(remaining, limit int64, resetAt, resetAfter float64, buc path: path, identifier: identifier, remaining: remaining, + resetAt: time.Unix(0, int64(resetAt*1_000_000_000)), limit: limit, period: slidePeriod, increaseAt: increaseAt, - fixedWindow: fixedWindow, + fixedWindow: false, } } @@ -136,6 +139,16 @@ func (b *BucketRateLimit) Update(remaining, limit int64, resetAt, resetAfter flo b.lock.Lock() defer b.lock.Unlock() + resetAtTime := time.Unix(0, int64(resetAt*1_000_000_000)) + + if b.resetAt.Equal(resetAtTime) { + // We cannot unfortunately detect these properly yet, so we need to do this hacky thing + // https://github.com/discord/discord-api-docs/issues/7680 + b.fixedWindow = true + } else if resetAtTime.After(b.resetAt) { + b.resetAt = resetAtTime + } + if increaseAt.Before(b.increaseAt) { // Old ratelimit information, ignore return From 1cf99a6bb8dc445211a72860a358c820856cf384 Mon Sep 17 00:00:00 2001 From: davfsa Date: Mon, 25 Aug 2025 14:32:11 +0200 Subject: [PATCH 028/105] feat: improve detection of fixed buckets --- lib/ratelimits.go | 58 +++++++++++++++++++++++++++++++++-------------- 1 file changed, 41 insertions(+), 17 deletions(-) diff --git a/lib/ratelimits.go b/lib/ratelimits.go index d5e9aa2..0e0e8a1 100644 --- a/lib/ratelimits.go +++ b/lib/ratelimits.go @@ -10,8 +10,8 @@ import ( ) // isClose checks if two durations are within `diff` seconds of difference -func isClose(a, b time.Duration, absTol float64) bool { - return math.Abs(a.Seconds()-b.Seconds()) <= absTol +func isClose(a, b float64, absTol float64) bool { + return math.Abs(a-b) <= absTol } func calculateSlidingWindow(remaining, limit int64, resetAt, resetAfter float64) (time.Duration, time.Time) { @@ -36,7 +36,7 @@ type BucketRateLimit struct { limit int64 period time.Duration increaseAt time.Time - resetAt time.Time + resetAt float64 outOfSync bool fixedWindow bool } @@ -54,7 +54,7 @@ func NewBucketRatelimit(remaining, limit int64, resetAt, resetAfter float64, buc path: path, identifier: identifier, remaining: remaining, - resetAt: time.Unix(0, int64(resetAt*1_000_000_000)), + resetAt: resetAt, limit: limit, period: slidePeriod, increaseAt: increaseAt, @@ -134,23 +134,46 @@ func (b *BucketRateLimit) Update(remaining, limit int64, resetAt, resetAfter flo return } - slidePeriod, increaseAt := calculateSlidingWindow(remaining, limit, resetAt, resetAfter) - b.lock.Lock() defer b.lock.Unlock() - resetAtTime := time.Unix(0, int64(resetAt*1_000_000_000)) + if resetAt < b.resetAt { + // Old ratelimit information, ignore + return + } - if b.resetAt.Equal(resetAtTime) { - // We cannot unfortunately detect these properly yet, so we need to do this hacky thing - // https://github.com/discord/discord-api-docs/issues/7680 - b.fixedWindow = true - } else if resetAtTime.After(b.resetAt) { - b.resetAt = resetAtTime + if !b.outOfSync { + resetAtEq := isClose(b.resetAt, resetAt, 0.05) + + if !b.fixedWindow && resetAtEq { + logger.WithFields(logrus.Fields{ + "bucket": b.bucket, + "path": b.path, + "identifier": b.identifier, + "storedResetAt": b.resetAt, + "receivedResetAt": resetAt, + }).Debug("Bucket detected to be a fixed bucket bucket") + b.fixedWindow = true + b.increaseAt = time.Unix(0, int64(resetAt*1_000_000_000)) + + } else if !b.fixedWindow && resetAtEq { + logger.WithFields(logrus.Fields{ + "bucket": b.bucket, + "path": b.path, + "identifier": b.identifier, + "storedResetAt": b.resetAt, + "receivedResetAt": resetAt, + }).Debug("Bucket stopped being a fixed bucket") + b.fixedWindow = false + // Setting this here will have an effect bellow + b.outOfSync = true + } } - if increaseAt.Before(b.increaseAt) { - // Old ratelimit information, ignore + b.resetAt = resetAt + + if b.fixedWindow { + b.outOfSync = false return } @@ -175,8 +198,9 @@ func (b *BucketRateLimit) Update(remaining, limit int64, resetAt, resetAfter flo // 3. The slide period increased // 4. The slide period greatly changed // Note: 0.3 and 0.5 are chosen arbitrarily after some testing - if b.outOfSync || remaining == limit-1 || slidePeriod > b.period || !isClose(slidePeriod, b.period, 0.3) { - if !isClose(slidePeriod, b.period, 0.5) { + slidePeriod, increaseAt := calculateSlidingWindow(remaining, limit, resetAt, resetAfter) + if b.outOfSync || remaining == limit-1 || slidePeriod > b.period || !isClose(slidePeriod.Seconds(), b.period.Seconds(), 0.3) { + if !isClose(slidePeriod.Seconds(), b.period.Seconds(), 0.5) { logger.WithFields(logrus.Fields{ "bucket": b.bucket, "path": b.path, From 1471b8fe658b3e134830a4a9c0589119ddbaa480 Mon Sep 17 00:00:00 2001 From: davfsa Date: Mon, 25 Aug 2025 14:36:03 +0200 Subject: [PATCH 029/105] fix: typo --- lib/ratelimits.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ratelimits.go b/lib/ratelimits.go index 0e0e8a1..eb0ccb8 100644 --- a/lib/ratelimits.go +++ b/lib/ratelimits.go @@ -165,7 +165,7 @@ func (b *BucketRateLimit) Update(remaining, limit int64, resetAt, resetAfter flo "receivedResetAt": resetAt, }).Debug("Bucket stopped being a fixed bucket") b.fixedWindow = false - // Setting this here will have an effect bellow + // Setting this here will have an effect below b.outOfSync = true } } From 4b3a71619b66caf888cb979ecf142db42a01c520 Mon Sep 17 00:00:00 2001 From: davfsa Date: Wed, 3 Sep 2025 20:01:00 +0200 Subject: [PATCH 030/105] feat: greatly improve ratelimit avoidance by tracking in transit requests --- lib/queue.go | 11 +++++-- lib/ratelimits.go | 82 +++++++++++++++++++++++++++++++++++------------ 2 files changed, 70 insertions(+), 23 deletions(-) diff --git a/lib/queue.go b/lib/queue.go index 284b337..70912da 100644 --- a/lib/queue.go +++ b/lib/queue.go @@ -3,9 +3,6 @@ package lib import ( "context" "errors" - "github.com/Clever/leakybucket" - "github.com/Clever/leakybucket/memory" - "github.com/sirupsen/logrus" "io" "net/http" "strconv" @@ -13,6 +10,10 @@ import ( "sync" "sync/atomic" "time" + + "github.com/Clever/leakybucket" + "github.com/Clever/leakybucket/memory" + "github.com/sirupsen/logrus" ) type QueueItem struct { @@ -297,6 +298,10 @@ func isInteraction(url string) bool { } func (item *QueueItem) doRequest(ctx context.Context, q *RequestQueue, ch *QueueChannel, path string, pathHash uint64) { + if ch.ratelimit != nil { + defer ch.ratelimit.Release() + } + resp, err := q.processor(ctx, item) if err != nil { item.errChan <- err diff --git a/lib/ratelimits.go b/lib/ratelimits.go index eb0ccb8..ebb983d 100644 --- a/lib/ratelimits.go +++ b/lib/ratelimits.go @@ -14,6 +14,13 @@ func isClose(a, b float64, absTol float64) bool { return math.Abs(a-b) <= absTol } +func calculateFixedWindow(resetAt, resetAfter float64) (time.Duration, time.Time) { + increaseAt := time.Unix(0, int64(resetAt*1_000_000_000)) + period := time.Duration(resetAfter*1_000) * time.Millisecond + + return period, increaseAt +} + func calculateSlidingWindow(remaining, limit int64, resetAt, resetAfter float64) (time.Duration, time.Time) { // slidePeriod = resetAfter / (limit - remaining) slidePeriod := time.Duration(math.Ceil((resetAfter/float64(limit-remaining))*1_000)) * time.Millisecond @@ -39,15 +46,14 @@ type BucketRateLimit struct { resetAt float64 outOfSync bool fixedWindow bool + + inTransitLock sync.Mutex + inTransit int64 + transitWaitChan chan interface{} } func NewBucketRatelimit(remaining, limit int64, resetAt, resetAfter float64, bucket, path, identifier string) *BucketRateLimit { - if remaining == limit { - // If we somehow get this case, then we cannot create a ratelimit from the info - return nil - } - - slidePeriod, increaseAt := calculateSlidingWindow(remaining, limit, resetAt, resetAfter) + period, increaseAt := calculateSlidingWindow(remaining, limit, resetAt, resetAfter) return &BucketRateLimit{ bucket: bucket, @@ -56,7 +62,7 @@ func NewBucketRatelimit(remaining, limit int64, resetAt, resetAfter float64, buc remaining: remaining, resetAt: resetAt, limit: limit, - period: slidePeriod, + period: period, increaseAt: increaseAt, fixedWindow: false, } @@ -68,7 +74,7 @@ func (b *BucketRateLimit) isRatelimited(now time.Time) bool { // network latency. // The second part of this 'if' is to account for some cases where there can be a race // condition and we receive rate limit updates out of order, and we cannot update `outOfSync` - if (now.After(b.increaseAt) || now.Equal(b.increaseAt)) && (!b.outOfSync || now.Sub(b.increaseAt) > b.period) { + if now.After(b.increaseAt) || now.Equal(b.increaseAt) { if b.fixedWindow { // Fixed windows just reset the remaining back to the limit b.remaining = b.limit @@ -98,6 +104,23 @@ func (b *BucketRateLimit) isRatelimited(now time.Time) bool { // Acquire will request a slot from the ratelimit and sleep until there is one available func (b *BucketRateLimit) Acquire(ctx context.Context) error { + b.inTransitLock.Lock() + if b.inTransit >= b.limit { + b.transitWaitChan = make(chan interface{}) + b.inTransitLock.Unlock() + select { + case <-ctx.Done(): + return ctx.Err() + case <-b.transitWaitChan: + } + // We dont update inTransit because the + // slot was given to us by the goroutine + // that sent the message through transitWaitChan + } else { + b.inTransit++ + b.inTransitLock.Unlock() + } + b.lock.Lock() defer b.lock.Unlock() @@ -128,12 +151,21 @@ func (b *BucketRateLimit) Acquire(ctx context.Context) error { return nil } -func (b *BucketRateLimit) Update(remaining, limit int64, resetAt, resetAfter float64) { - if remaining == limit { - // This should never happen, but just in case - return +func (b *BucketRateLimit) Release() { + b.inTransitLock.Lock() + b.inTransitLock.Unlock() + + if b.transitWaitChan != nil && b.inTransit <= b.limit { + // We dont update inTransit here as we are giving + // our slot to the one that is waiting + b.transitWaitChan <- nil + b.transitWaitChan = nil + } else { + b.inTransit-- } +} +func (b *BucketRateLimit) Update(remaining, limit int64, resetAt, resetAfter float64) { b.lock.Lock() defer b.lock.Unlock() @@ -152,9 +184,10 @@ func (b *BucketRateLimit) Update(remaining, limit int64, resetAt, resetAfter flo "identifier": b.identifier, "storedResetAt": b.resetAt, "receivedResetAt": resetAt, - }).Debug("Bucket detected to be a fixed bucket bucket") + }).Info("Bucket detected to be a fixed bucket bucket") b.fixedWindow = true - b.increaseAt = time.Unix(0, int64(resetAt*1_000_000_000)) + // Setting this here will have an effect below + b.outOfSync = true } else if !b.fixedWindow && resetAtEq { logger.WithFields(logrus.Fields{ @@ -163,7 +196,7 @@ func (b *BucketRateLimit) Update(remaining, limit int64, resetAt, resetAfter flo "identifier": b.identifier, "storedResetAt": b.resetAt, "receivedResetAt": resetAt, - }).Debug("Bucket stopped being a fixed bucket") + }).Info("Bucket stopped being a fixed bucket") b.fixedWindow = false // Setting this here will have an effect below b.outOfSync = true @@ -172,11 +205,6 @@ func (b *BucketRateLimit) Update(remaining, limit int64, resetAt, resetAfter flo b.resetAt = resetAt - if b.fixedWindow { - b.outOfSync = false - return - } - if b.limit != limit { if b.limit > limit { logger.WithFields(logrus.Fields{ @@ -192,6 +220,20 @@ func (b *BucketRateLimit) Update(remaining, limit int64, resetAt, resetAfter flo b.remaining = min(b.remaining, b.limit) } + if b.fixedWindow { + // We want to update the period only, and only if: + // 1. The bucket is out of sync (ie, we reset the full window) + // 2. We receive the first usage of the bucket, which will always have correct period + if b.outOfSync || remaining == limit-1 { + period, increaseAt := calculateFixedWindow(b.resetAt, resetAfter) + b.period = period + b.increaseAt = increaseAt + + b.outOfSync = false + } + return + } + // We want to update the slide period only, and only if: // 1. The bucket is out of sync (ie, we reset the full window) // 2. We receive the first usage of the bucket, which will always have the most accurate slide period From e5207cc64eacbf982409d9f0a18e4365850f42eb Mon Sep 17 00:00:00 2001 From: davfsa Date: Wed, 3 Sep 2025 20:03:46 +0200 Subject: [PATCH 031/105] chore: drop logging level --- lib/ratelimits.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/ratelimits.go b/lib/ratelimits.go index ebb983d..20a8a6d 100644 --- a/lib/ratelimits.go +++ b/lib/ratelimits.go @@ -184,7 +184,7 @@ func (b *BucketRateLimit) Update(remaining, limit int64, resetAt, resetAfter flo "identifier": b.identifier, "storedResetAt": b.resetAt, "receivedResetAt": resetAt, - }).Info("Bucket detected to be a fixed bucket bucket") + }).Debug("Bucket detected to be a fixed bucket bucket") b.fixedWindow = true // Setting this here will have an effect below b.outOfSync = true @@ -196,7 +196,7 @@ func (b *BucketRateLimit) Update(remaining, limit int64, resetAt, resetAfter flo "identifier": b.identifier, "storedResetAt": b.resetAt, "receivedResetAt": resetAt, - }).Info("Bucket stopped being a fixed bucket") + }).Debug("Bucket stopped being a fixed bucket") b.fixedWindow = false // Setting this here will have an effect below b.outOfSync = true From 8babac18a4e36bc635808b4d1d3b3f55b6f54244 Mon Sep 17 00:00:00 2001 From: davfsa Date: Wed, 3 Sep 2025 20:05:25 +0200 Subject: [PATCH 032/105] chore: readd self-healing logic --- lib/ratelimits.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ratelimits.go b/lib/ratelimits.go index 20a8a6d..c64d0d4 100644 --- a/lib/ratelimits.go +++ b/lib/ratelimits.go @@ -74,7 +74,7 @@ func (b *BucketRateLimit) isRatelimited(now time.Time) bool { // network latency. // The second part of this 'if' is to account for some cases where there can be a race // condition and we receive rate limit updates out of order, and we cannot update `outOfSync` - if now.After(b.increaseAt) || now.Equal(b.increaseAt) { + if now.After(b.increaseAt) || now.Equal(b.increaseAt) && (!b.outOfSync || now.Sub(b.increaseAt) > b.period) { if b.fixedWindow { // Fixed windows just reset the remaining back to the limit b.remaining = b.limit From 287eab30fbf5e563842843218a3fa241e1aa1c20 Mon Sep 17 00:00:00 2001 From: davfsa Date: Wed, 3 Sep 2025 21:08:11 +0200 Subject: [PATCH 033/105] chore: bump go version --- go.mod | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/go.mod b/go.mod index b455dae..c234604 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,6 @@ module github.com/germanoeich/nirn-proxy -go 1.23.0 - -toolchain go1.24.4 +go 1.25 require ( github.com/Clever/leakybucket v1.2.0 From 2ca5d872a85bf1b192deec8ed1fd76c9ce04349b Mon Sep 17 00:00:00 2001 From: davfsa Date: Thu, 4 Sep 2025 09:56:00 +0200 Subject: [PATCH 034/105] chore: update bucket name when updating ratelimiter --- lib/queue.go | 2 +- lib/ratelimits.go | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/queue.go b/lib/queue.go index 70912da..a6b1868 100644 --- a/lib/queue.go +++ b/lib/queue.go @@ -338,7 +338,7 @@ func (item *QueueItem) doRequest(ctx context.Context, q *RequestQueue, ch *Queue } ch.Unlock() } else { - ch.ratelimit.Update(remaining, limit, resetAt, resetAfter) + ch.ratelimit.Update(bucket, remaining, limit, resetAt, resetAfter) } } diff --git a/lib/ratelimits.go b/lib/ratelimits.go index c64d0d4..a3a3065 100644 --- a/lib/ratelimits.go +++ b/lib/ratelimits.go @@ -165,7 +165,7 @@ func (b *BucketRateLimit) Release() { } } -func (b *BucketRateLimit) Update(remaining, limit int64, resetAt, resetAfter float64) { +func (b *BucketRateLimit) Update(bucket string, remaining, limit int64, resetAt, resetAfter float64) { b.lock.Lock() defer b.lock.Unlock() @@ -174,6 +174,8 @@ func (b *BucketRateLimit) Update(remaining, limit int64, resetAt, resetAfter flo return } + b.bucket = bucket + if !b.outOfSync { resetAtEq := isClose(b.resetAt, resetAt, 0.05) From 04fb1e413204ff534f8e56d17c1dea4a773b2b6b Mon Sep 17 00:00:00 2001 From: davfsa Date: Mon, 15 Sep 2025 18:27:50 +0200 Subject: [PATCH 035/105] chore: improve struct alignment --- lib/ratelimits.go | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/lib/ratelimits.go b/lib/ratelimits.go index a3a3065..4657cfa 100644 --- a/lib/ratelimits.go +++ b/lib/ratelimits.go @@ -35,21 +35,22 @@ func calculateSlidingWindow(remaining, limit int64, resetAt, resetAfter float64) // BucketRateLimit is a sliding window ratelimit implementation type BucketRateLimit struct { - identifier string - path string - bucket string - lock sync.Mutex - remaining int64 - limit int64 - period time.Duration - increaseAt time.Time - resetAt float64 - outOfSync bool - fixedWindow bool + identifier string + path string + bucket string + lock sync.Mutex + remaining int64 + limit int64 + period time.Duration + increaseAt time.Time + resetAt float64 inTransitLock sync.Mutex inTransit int64 transitWaitChan chan interface{} + + outOfSync bool + fixedWindow bool } func NewBucketRatelimit(remaining, limit int64, resetAt, resetAfter float64, bucket, path, identifier string) *BucketRateLimit { From 6d64c9d53c19b8be7b637f46bc9d8e94d3aae73e Mon Sep 17 00:00:00 2001 From: davfsa Date: Tue, 16 Sep 2025 22:55:28 +0200 Subject: [PATCH 036/105] fix: fix issues from review --- lib/queue.go | 3 ++- lib/ratelimits.go | 10 +++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/lib/queue.go b/lib/queue.go index a6b1868..47bbc1f 100644 --- a/lib/queue.go +++ b/lib/queue.go @@ -398,7 +398,8 @@ func (q *RequestQueue) subscribe(ch *QueueChannel, path string, pathHash uint64) } if globalLockedUntil := atomic.LoadInt64(q.globalLockedUntil); globalLockedUntil > 0 { - time.Sleep(time.Duration(globalLockedUntil)) + time.Sleep(time.Until(time.Unix(0, globalLockedUntil))) + atomic.StoreInt64(q.globalLockedUntil, 0) } if ch.lockerFun != nil { diff --git a/lib/ratelimits.go b/lib/ratelimits.go index 4657cfa..a6e64c6 100644 --- a/lib/ratelimits.go +++ b/lib/ratelimits.go @@ -111,6 +111,14 @@ func (b *BucketRateLimit) Acquire(ctx context.Context) error { b.inTransitLock.Unlock() select { case <-ctx.Done(): + b.inTransitLock.Lock() + if b.transitWaitChan != nil { + b.transitWaitChan = nil + } else { + // Return the slot that was given to us + b.inTransit-- + } + b.inTransitLock.Unlock() return ctx.Err() case <-b.transitWaitChan: } @@ -192,7 +200,7 @@ func (b *BucketRateLimit) Update(bucket string, remaining, limit int64, resetAt, // Setting this here will have an effect below b.outOfSync = true - } else if !b.fixedWindow && resetAtEq { + } else if b.fixedWindow && !resetAtEq { logger.WithFields(logrus.Fields{ "bucket": b.bucket, "path": b.path, From 69cc2d24da1644db43f4ab00515ad23907f694de Mon Sep 17 00:00:00 2001 From: davfsa Date: Tue, 16 Sep 2025 23:24:31 +0200 Subject: [PATCH 037/105] feat: add anti-deadlock measures --- lib/queue.go | 1 + lib/ratelimits.go | 9 ++++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/queue.go b/lib/queue.go index 47bbc1f..12137d5 100644 --- a/lib/queue.go +++ b/lib/queue.go @@ -416,6 +416,7 @@ func (q *RequestQueue) subscribe(ch *QueueChannel, path string, pathHash uint64) item.errChan <- err continue } + _ = item.Req.Body.Close() if ch.ratelimit != nil { if err := ch.ratelimit.Acquire(ctx); err != nil { diff --git a/lib/ratelimits.go b/lib/ratelimits.go index a6e64c6..cf90cc7 100644 --- a/lib/ratelimits.go +++ b/lib/ratelimits.go @@ -107,7 +107,8 @@ func (b *BucketRateLimit) isRatelimited(now time.Time) bool { func (b *BucketRateLimit) Acquire(ctx context.Context) error { b.inTransitLock.Lock() if b.inTransit >= b.limit { - b.transitWaitChan = make(chan interface{}) + // Buffer of 1 here to prevent deadlocks in a worst case scenario + b.transitWaitChan = make(chan interface{}, 1) b.inTransitLock.Unlock() select { case <-ctx.Done(): @@ -162,14 +163,16 @@ func (b *BucketRateLimit) Acquire(ctx context.Context) error { func (b *BucketRateLimit) Release() { b.inTransitLock.Lock() - b.inTransitLock.Unlock() + defer b.inTransitLock.Unlock() if b.transitWaitChan != nil && b.inTransit <= b.limit { // We dont update inTransit here as we are giving // our slot to the one that is waiting b.transitWaitChan <- nil b.transitWaitChan = nil - } else { + } + + if b.inTransit > 0 { b.inTransit-- } } From 75e7600567901a93f18a8e22f50174ea63a6a3e3 Mon Sep 17 00:00:00 2001 From: davfsa Date: Tue, 16 Sep 2025 23:29:34 +0200 Subject: [PATCH 038/105] fix: add missing return --- lib/ratelimits.go | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/ratelimits.go b/lib/ratelimits.go index cf90cc7..1ed66c2 100644 --- a/lib/ratelimits.go +++ b/lib/ratelimits.go @@ -170,6 +170,7 @@ func (b *BucketRateLimit) Release() { // our slot to the one that is waiting b.transitWaitChan <- nil b.transitWaitChan = nil + return } if b.inTransit > 0 { From 721b393f90615fa34676addab360bac145a29307 Mon Sep 17 00:00:00 2001 From: davfsa Date: Tue, 16 Sep 2025 23:34:43 +0200 Subject: [PATCH 039/105] chore: do not shadow err variable --- lib/queue.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/queue.go b/lib/queue.go index 12137d5..cc2eba4 100644 --- a/lib/queue.go +++ b/lib/queue.go @@ -419,7 +419,7 @@ func (q *RequestQueue) subscribe(ch *QueueChannel, path string, pathHash uint64) _ = item.Req.Body.Close() if ch.ratelimit != nil { - if err := ch.ratelimit.Acquire(ctx); err != nil { + if err = ch.ratelimit.Acquire(ctx); err != nil { item.errChan <- err continue } From 5c3c0bb6f00a172a9b986eda1e4450671b36eaf9 Mon Sep 17 00:00:00 2001 From: davfsa Date: Tue, 16 Sep 2025 23:37:52 +0200 Subject: [PATCH 040/105] feat: improve global locked until sleep --- lib/queue.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/queue.go b/lib/queue.go index cc2eba4..3f8389b 100644 --- a/lib/queue.go +++ b/lib/queue.go @@ -397,9 +397,11 @@ func (q *RequestQueue) subscribe(ch *QueueChannel, path string, pathHash uint64) continue } - if globalLockedUntil := atomic.LoadInt64(q.globalLockedUntil); globalLockedUntil > 0 { - time.Sleep(time.Until(time.Unix(0, globalLockedUntil))) - atomic.StoreInt64(q.globalLockedUntil, 0) + if globalUnlockedUntil := atomic.LoadInt64(q.globalLockedUntil); globalUnlockedUntil > 0 { + if d := time.Until(time.Unix(0, globalUnlockedUntil)); d > 0 { + time.Sleep(d) + } + _ = atomic.CompareAndSwapInt64(q.globalLockedUntil, globalUnlockedUntil, 0) } if ch.lockerFun != nil { From 63c7f4e8b3a58a3de4de9078a33d25ba11ef6ca8 Mon Sep 17 00:00:00 2001 From: davfsa Date: Wed, 17 Sep 2025 00:37:09 +0200 Subject: [PATCH 041/105] chore: cleanup locks and add some documentation --- lib/queue.go | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/lib/queue.go b/lib/queue.go index 3f8389b..6a9e1ac 100644 --- a/lib/queue.go +++ b/lib/queue.go @@ -298,6 +298,8 @@ func isInteraction(url string) bool { } func (item *QueueItem) doRequest(ctx context.Context, q *RequestQueue, ch *QueueChannel, path string, pathHash uint64) { + // This is fine to do as if ch.ratelimit is nil, then we have sole access to the RequestQueue resources, so there + // is no need for a lock if ch.ratelimit != nil { defer ch.ratelimit.Release() } @@ -332,11 +334,10 @@ func (item *QueueItem) doRequest(ctx context.Context, q *RequestQueue, ch *Queue if bucket != "" { if ch.ratelimit == nil { - ch.Lock() - if ch.ratelimit == nil { - ch.ratelimit = NewBucketRatelimit(remaining, limit, resetAt, resetAfter, bucket, path, q.identifier) - } - ch.Unlock() + // We can safely do this as it is ensured that if ch.ratelimit is not set, we will always + // make sequential requests and not concurrent ones. The first request that gets a ratelimit bucket + // will set the ratelimit and it wont be set back to nil afterwards + ch.ratelimit = NewBucketRatelimit(remaining, limit, resetAt, resetAfter, bucket, path, q.identifier) } else { ch.ratelimit.Update(bucket, remaining, limit, resetAt, resetAfter) } @@ -404,10 +405,12 @@ func (q *RequestQueue) subscribe(ch *QueueChannel, path string, pathHash uint64) _ = atomic.CompareAndSwapInt64(q.globalLockedUntil, globalUnlockedUntil, 0) } + ch.Lock() if ch.lockerFun != nil { ch.lockerFun(item) continue } + ch.Unlock() // This is unfortunate, but we need to read the body here so that the ctx gets closed properly // when the client disconnects, which is very useful for cancelling `ratelimit.Acquire` early @@ -415,6 +418,7 @@ func (q *RequestQueue) subscribe(ch *QueueChannel, path string, pathHash uint64) var err error item.ReqBody, err = io.ReadAll(item.Req.Body) if err != nil { + _ = item.Req.Body.Close() item.errChan <- err continue } From 9ac55a53cbd134e09f67f096dbe516668014342d Mon Sep 17 00:00:00 2001 From: davfsa Date: Fri, 19 Sep 2025 08:33:26 +0200 Subject: [PATCH 042/105] fix: add missing unlock to prevent a deadlock --- lib/queue.go | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/queue.go b/lib/queue.go index 6a9e1ac..2598f0e 100644 --- a/lib/queue.go +++ b/lib/queue.go @@ -408,6 +408,7 @@ func (q *RequestQueue) subscribe(ch *QueueChannel, path string, pathHash uint64) ch.Lock() if ch.lockerFun != nil { ch.lockerFun(item) + ch.Unlock() continue } ch.Unlock() From 5a33b04a1c77af5dd83d1d3452ac97a8b7df6011 Mon Sep 17 00:00:00 2001 From: davfsa Date: Fri, 19 Sep 2025 10:03:23 +0200 Subject: [PATCH 043/105] fix: release bucket before erroring when context is done --- lib/ratelimits.go | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/ratelimits.go b/lib/ratelimits.go index 1ed66c2..7400295 100644 --- a/lib/ratelimits.go +++ b/lib/ratelimits.go @@ -151,6 +151,7 @@ func (b *BucketRateLimit) Acquire(ctx context.Context) error { select { case <-ctx.Done(): + b.Release() return ctx.Err() case <-time.After(sleepDuration): } From e55351c7474fde2efff9d09d08e4eb4d9d49b614 Mon Sep 17 00:00:00 2001 From: davfsa Date: Fri, 19 Sep 2025 10:08:12 +0200 Subject: [PATCH 044/105] fix: add more precise lock section --- lib/ratelimits.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/ratelimits.go b/lib/ratelimits.go index 7400295..4e2e089 100644 --- a/lib/ratelimits.go +++ b/lib/ratelimits.go @@ -104,6 +104,7 @@ func (b *BucketRateLimit) isRatelimited(now time.Time) bool { } // Acquire will request a slot from the ratelimit and sleep until there is one available +// NOTE: This function does not support concurrent calls! func (b *BucketRateLimit) Acquire(ctx context.Context) error { b.inTransitLock.Lock() if b.inTransit >= b.limit { @@ -131,16 +132,16 @@ func (b *BucketRateLimit) Acquire(ctx context.Context) error { b.inTransitLock.Unlock() } - b.lock.Lock() - defer b.lock.Unlock() - for { + b.lock.Lock() now := time.Now() if !b.isRatelimited(now) { + // b.lock will be unlocked after decrementing remaining break } - sleepDuration := b.increaseAt.Sub(now) + b.lock.Unlock() + if sleepDuration > 0 { logger.WithFields(logrus.Fields{ "bucket": b.bucket, @@ -159,6 +160,7 @@ func (b *BucketRateLimit) Acquire(ctx context.Context) error { } b.remaining-- + b.lock.Unlock() return nil } From 49cb4555a001a99db4336e33bad08690e54bfd79 Mon Sep 17 00:00:00 2001 From: davfsa Date: Wed, 1 Oct 2025 17:48:55 +0200 Subject: [PATCH 045/105] chore: make ratelimiter never nil --- lib/queue.go | 29 ++++++++--------------------- lib/ratelimits.go | 41 ++++++++++++++++++++++++++++------------- 2 files changed, 36 insertions(+), 34 deletions(-) diff --git a/lib/queue.go b/lib/queue.go index 2598f0e..4567a17 100644 --- a/lib/queue.go +++ b/lib/queue.go @@ -28,7 +28,7 @@ type QueueChannel struct { sync.Mutex ch chan *QueueItem lastUsed time.Time - ratelimit *BucketRateLimit + ratelimit BucketRateLimit lockerFun func(item *QueueItem) } @@ -193,7 +193,7 @@ func (q *RequestQueue) getQueueChannel(path string, pathHash uint64) *QueueChann ch = &QueueChannel{ ch: make(chan *QueueItem, q.bufferSize), lastUsed: t, - ratelimit: nil, + ratelimit: NewBucketRatelimit(path, q.identifier), } q.queues[pathHash] = ch // It's important that we only have 1 goroutine per channel @@ -298,11 +298,7 @@ func isInteraction(url string) bool { } func (item *QueueItem) doRequest(ctx context.Context, q *RequestQueue, ch *QueueChannel, path string, pathHash uint64) { - // This is fine to do as if ch.ratelimit is nil, then we have sole access to the RequestQueue resources, so there - // is no need for a lock - if ch.ratelimit != nil { - defer ch.ratelimit.Release() - } + defer ch.ratelimit.Release() resp, err := q.processor(ctx, item) if err != nil { @@ -333,14 +329,7 @@ func (item *QueueItem) doRequest(ctx context.Context, q *RequestQueue, ch *Queue item.doneChan <- resp if bucket != "" { - if ch.ratelimit == nil { - // We can safely do this as it is ensured that if ch.ratelimit is not set, we will always - // make sequential requests and not concurrent ones. The first request that gets a ratelimit bucket - // will set the ratelimit and it wont be set back to nil afterwards - ch.ratelimit = NewBucketRatelimit(remaining, limit, resetAt, resetAfter, bucket, path, q.identifier) - } else { - ch.ratelimit.Update(bucket, remaining, limit, resetAt, resetAfter) - } + ch.ratelimit.Update(bucket, remaining, limit, resetAt, resetAfter) } if resp.StatusCode == 429 && scope != "shared" { @@ -425,11 +414,9 @@ func (q *RequestQueue) subscribe(ch *QueueChannel, path string, pathHash uint64) } _ = item.Req.Body.Close() - if ch.ratelimit != nil { - if err = ch.ratelimit.Acquire(ctx); err != nil { - item.errChan <- err - continue - } + if err = ch.ratelimit.Acquire(ctx); err != nil { + item.errChan <- err + continue } // We don't have the initial headers, so we do the requests sequentially, which should @@ -438,7 +425,7 @@ func (q *RequestQueue) subscribe(ch *QueueChannel, path string, pathHash uint64) // which should be fine // // TODO: Consider if its worth hard coding which routes will never have a bucket - if ch.ratelimit == nil || !allowConcurrentRequests { + if ch.ratelimit.unknown || !allowConcurrentRequests { item.doRequest(ctx, q, ch, path, pathHash) } else { go item.doRequest(ctx, q, ch, path, pathHash) diff --git a/lib/ratelimits.go b/lib/ratelimits.go index 4e2e089..ef3791f 100644 --- a/lib/ratelimits.go +++ b/lib/ratelimits.go @@ -49,28 +49,28 @@ type BucketRateLimit struct { inTransit int64 transitWaitChan chan interface{} + unknown bool outOfSync bool fixedWindow bool } -func NewBucketRatelimit(remaining, limit int64, resetAt, resetAfter float64, bucket, path, identifier string) *BucketRateLimit { - period, increaseAt := calculateSlidingWindow(remaining, limit, resetAt, resetAfter) - - return &BucketRateLimit{ - bucket: bucket, - path: path, - identifier: identifier, - remaining: remaining, - resetAt: resetAt, - limit: limit, - period: period, - increaseAt: increaseAt, - fixedWindow: false, +func NewBucketRatelimit(path, identifier string) BucketRateLimit { + return BucketRateLimit{ + path: path, + identifier: identifier, + limit: 1, + unknown: true, } } // Note: this MUST be called from a locked state func (b *BucketRateLimit) isRatelimited(now time.Time) bool { + if b.unknown { + // Don't do any waiting logic as we don't have any information on the bucket, + // just do the request immediately + return false + } + // If we are out of sync, we shouldn't slide the window along, as we will be off due to // network latency. // The second part of this 'if' is to account for some cases where there can be a race @@ -185,6 +185,21 @@ func (b *BucketRateLimit) Update(bucket string, remaining, limit int64, resetAt, b.lock.Lock() defer b.lock.Unlock() + if b.unknown { + period, increaseAt := calculateSlidingWindow(remaining, limit, resetAt, resetAfter) + + b.bucket = bucket + b.period = period + b.resetAt = resetAt + b.increaseAt = increaseAt + b.remaining = remaining + b.limit = limit + b.outOfSync = false + b.fixedWindow = false + b.unknown = false + return + } + if resetAt < b.resetAt { // Old ratelimit information, ignore return From 2eca7d1701fe2cf17d0f89d0152bdf3da0ad4299 Mon Sep 17 00:00:00 2001 From: davfsa Date: Wed, 1 Oct 2025 18:30:21 +0200 Subject: [PATCH 046/105] chore: microoptimization --- lib/ratelimits.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ratelimits.go b/lib/ratelimits.go index ef3791f..bb7f843 100644 --- a/lib/ratelimits.go +++ b/lib/ratelimits.go @@ -250,7 +250,7 @@ func (b *BucketRateLimit) Update(bucket string, remaining, limit int64, resetAt, } b.limit = limit - b.remaining = min(b.remaining, b.limit) + b.remaining = min(b.remaining, limit) } if b.fixedWindow { From 7aac3a68b466c884c2c9eeb99836bdf0fefe7a26 Mon Sep 17 00:00:00 2001 From: davfsa Date: Tue, 7 Oct 2025 15:39:57 +0200 Subject: [PATCH 047/105] Apply suggestion from @davfsa --- lib/ratelimits.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/ratelimits.go b/lib/ratelimits.go index bb7f843..ec20b7b 100644 --- a/lib/ratelimits.go +++ b/lib/ratelimits.go @@ -67,7 +67,8 @@ func NewBucketRatelimit(path, identifier string) BucketRateLimit { func (b *BucketRateLimit) isRatelimited(now time.Time) bool { if b.unknown { // Don't do any waiting logic as we don't have any information on the bucket, - // just do the request immediately + // just do the request immediately. The first successful request will return the bucket + // data return false } From 62e4029eafe9c41bd106719d1fdd9bc68e5c7526 Mon Sep 17 00:00:00 2001 From: davfsa Date: Sat, 13 Dec 2025 11:55:20 +0100 Subject: [PATCH 048/105] fix: improve logic to detect fixed buckets --- lib/ratelimits.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/ratelimits.go b/lib/ratelimits.go index ec20b7b..fafb037 100644 --- a/lib/ratelimits.go +++ b/lib/ratelimits.go @@ -63,7 +63,7 @@ func NewBucketRatelimit(path, identifier string) BucketRateLimit { } } -// Note: this MUST be called from a locked state +// Warning: this MUST be called from a locked state func (b *BucketRateLimit) isRatelimited(now time.Time) bool { if b.unknown { // Don't do any waiting logic as we don't have any information on the bucket, @@ -208,7 +208,7 @@ func (b *BucketRateLimit) Update(bucket string, remaining, limit int64, resetAt, b.bucket = bucket - if !b.outOfSync { + if !b.outOfSync && remaining < b.remaining+b.inTransit { resetAtEq := isClose(b.resetAt, resetAt, 0.05) if !b.fixedWindow && resetAtEq { @@ -218,7 +218,7 @@ func (b *BucketRateLimit) Update(bucket string, remaining, limit int64, resetAt, "identifier": b.identifier, "storedResetAt": b.resetAt, "receivedResetAt": resetAt, - }).Debug("Bucket detected to be a fixed bucket bucket") + }).Debug("Bucket detected to be a fixed bucket") b.fixedWindow = true // Setting this here will have an effect below b.outOfSync = true From de5eca42490d8e9c80415423a0eb166f0a37c2d5 Mon Sep 17 00:00:00 2001 From: davfsa Date: Sat, 13 Dec 2025 13:23:30 +0100 Subject: [PATCH 049/105] fix: decrease sliding precision --- lib/ratelimits.go | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/lib/ratelimits.go b/lib/ratelimits.go index fafb037..0318585 100644 --- a/lib/ratelimits.go +++ b/lib/ratelimits.go @@ -74,9 +74,9 @@ func (b *BucketRateLimit) isRatelimited(now time.Time) bool { // If we are out of sync, we shouldn't slide the window along, as we will be off due to // network latency. - // The second part of this 'if' is to account for some cases where there can be a race - // condition and we receive rate limit updates out of order, and we cannot update `outOfSync` - if now.After(b.increaseAt) || now.Equal(b.increaseAt) && (!b.outOfSync || now.Sub(b.increaseAt) > b.period) { + // The second part of this 'if' is for self-healing reasons, to account for the weird case where + // an error occurs and the bucket is not updated properly, becoming permanently out of sync + if now.After(b.increaseAt) && (!b.outOfSync || now.Sub(b.increaseAt) > b.period) { if b.fixedWindow { // Fixed windows just reset the remaining back to the limit b.remaining = b.limit @@ -186,6 +186,16 @@ func (b *BucketRateLimit) Update(bucket string, remaining, limit int64, resetAt, b.lock.Lock() defer b.lock.Unlock() + logger.WithFields(logrus.Fields{ + "bucket": b.bucket, + "path": b.path, + "identifier": b.identifier, + "remaining": remaining, + "limit": remaining, + "resetAt": resetAt, + "resetAfter": resetAfter, + }).Debug("updating bucket ratelimit") + if b.unknown { period, increaseAt := calculateSlidingWindow(remaining, limit, resetAt, resetAfter) From d453f4d7587c5a8122e55f1325910317cd1612d5 Mon Sep 17 00:00:00 2001 From: davfsa Date: Sat, 13 Dec 2025 13:34:33 +0100 Subject: [PATCH 050/105] fix: proper order of logic --- lib/ratelimits.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ratelimits.go b/lib/ratelimits.go index 0318585..dc75696 100644 --- a/lib/ratelimits.go +++ b/lib/ratelimits.go @@ -76,7 +76,7 @@ func (b *BucketRateLimit) isRatelimited(now time.Time) bool { // network latency. // The second part of this 'if' is for self-healing reasons, to account for the weird case where // an error occurs and the bucket is not updated properly, becoming permanently out of sync - if now.After(b.increaseAt) && (!b.outOfSync || now.Sub(b.increaseAt) > b.period) { + if (now.After(b.increaseAt) || now.Equal(b.increaseAt)) && (!b.outOfSync || now.Sub(b.increaseAt) > b.period) { if b.fixedWindow { // Fixed windows just reset the remaining back to the limit b.remaining = b.limit From 649bfb0b0745083f92a15ae5ff5073dd95b773bf Mon Sep 17 00:00:00 2001 From: davfsa Date: Sat, 13 Dec 2025 17:11:48 +0100 Subject: [PATCH 051/105] feat: improve detection of fixed buckets Assume they will never change from fixed to sliding. This can be massively cleaned up if Discord just exposed the information Signed-off-by: davfsa --- lib/ratelimits.go | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/lib/ratelimits.go b/lib/ratelimits.go index dc75696..7d97f4d 100644 --- a/lib/ratelimits.go +++ b/lib/ratelimits.go @@ -52,6 +52,7 @@ type BucketRateLimit struct { unknown bool outOfSync bool fixedWindow bool + newBucket bool } func NewBucketRatelimit(path, identifier string) BucketRateLimit { @@ -208,6 +209,7 @@ func (b *BucketRateLimit) Update(bucket string, remaining, limit int64, resetAt, b.outOfSync = false b.fixedWindow = false b.unknown = false + b.newBucket = true return } @@ -216,9 +218,8 @@ func (b *BucketRateLimit) Update(bucket string, remaining, limit int64, resetAt, return } - b.bucket = bucket - - if !b.outOfSync && remaining < b.remaining+b.inTransit { + if b.newBucket { + b.newBucket = false resetAtEq := isClose(b.resetAt, resetAt, 0.05) if !b.fixedWindow && resetAtEq { @@ -247,6 +248,14 @@ func (b *BucketRateLimit) Update(bucket string, remaining, limit int64, resetAt, } } + if bucket != b.bucket { + // The bucket changed, reset some of its state + b.bucket = bucket + b.newBucket = true + // Setting this here will have an effect below + b.outOfSync = true + } + b.resetAt = resetAt if b.limit != limit { From 4b66b81ba296e54dad0ddb1fb89ba4fc3a4da1c1 Mon Sep 17 00:00:00 2001 From: davfsa Date: Sun, 14 Dec 2025 15:07:31 +0100 Subject: [PATCH 052/105] Revert "feat: improve detection of fixed buckets" This reverts commit 649bfb0b0745083f92a15ae5ff5073dd95b773bf. --- lib/ratelimits.go | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/lib/ratelimits.go b/lib/ratelimits.go index 7d97f4d..dc75696 100644 --- a/lib/ratelimits.go +++ b/lib/ratelimits.go @@ -52,7 +52,6 @@ type BucketRateLimit struct { unknown bool outOfSync bool fixedWindow bool - newBucket bool } func NewBucketRatelimit(path, identifier string) BucketRateLimit { @@ -209,7 +208,6 @@ func (b *BucketRateLimit) Update(bucket string, remaining, limit int64, resetAt, b.outOfSync = false b.fixedWindow = false b.unknown = false - b.newBucket = true return } @@ -218,8 +216,9 @@ func (b *BucketRateLimit) Update(bucket string, remaining, limit int64, resetAt, return } - if b.newBucket { - b.newBucket = false + b.bucket = bucket + + if !b.outOfSync && remaining < b.remaining+b.inTransit { resetAtEq := isClose(b.resetAt, resetAt, 0.05) if !b.fixedWindow && resetAtEq { @@ -248,14 +247,6 @@ func (b *BucketRateLimit) Update(bucket string, remaining, limit int64, resetAt, } } - if bucket != b.bucket { - // The bucket changed, reset some of its state - b.bucket = bucket - b.newBucket = true - // Setting this here will have an effect below - b.outOfSync = true - } - b.resetAt = resetAt if b.limit != limit { From af4b41efcb57f693641e7b685a94f087bff4172d Mon Sep 17 00:00:00 2001 From: davfsa Date: Mon, 15 Dec 2025 08:53:39 +0100 Subject: [PATCH 053/105] chore: treat bucket as fixed by default Should help in cases where the bucket is hit in the middle of a slide, like when nirn is restarted in the middle of it's runtime with pending requests --- lib/ratelimits.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/ratelimits.go b/lib/ratelimits.go index dc75696..3545ed3 100644 --- a/lib/ratelimits.go +++ b/lib/ratelimits.go @@ -197,7 +197,7 @@ func (b *BucketRateLimit) Update(bucket string, remaining, limit int64, resetAt, }).Debug("updating bucket ratelimit") if b.unknown { - period, increaseAt := calculateSlidingWindow(remaining, limit, resetAt, resetAfter) + period, increaseAt := calculateFixedWindow(resetAt, resetAfter) b.bucket = bucket b.period = period @@ -206,7 +206,7 @@ func (b *BucketRateLimit) Update(bucket string, remaining, limit int64, resetAt, b.remaining = remaining b.limit = limit b.outOfSync = false - b.fixedWindow = false + b.fixedWindow = true b.unknown = false return } @@ -269,7 +269,7 @@ func (b *BucketRateLimit) Update(bucket string, remaining, limit int64, resetAt, // 1. The bucket is out of sync (ie, we reset the full window) // 2. We receive the first usage of the bucket, which will always have correct period if b.outOfSync || remaining == limit-1 { - period, increaseAt := calculateFixedWindow(b.resetAt, resetAfter) + period, increaseAt := calculateFixedWindow(resetAt, resetAfter) b.period = period b.increaseAt = increaseAt From 415794aba52695d9b8d962c543bbc906bddbf37d Mon Sep 17 00:00:00 2001 From: davfsa Date: Mon, 15 Dec 2025 10:35:30 +0100 Subject: [PATCH 054/105] chore: only update bucket type if not first ratelimit information --- lib/ratelimits.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ratelimits.go b/lib/ratelimits.go index 3545ed3..090e594 100644 --- a/lib/ratelimits.go +++ b/lib/ratelimits.go @@ -218,7 +218,7 @@ func (b *BucketRateLimit) Update(bucket string, remaining, limit int64, resetAt, b.bucket = bucket - if !b.outOfSync && remaining < b.remaining+b.inTransit { + if !b.outOfSync && remaining < limit - 1 { resetAtEq := isClose(b.resetAt, resetAt, 0.05) if !b.fixedWindow && resetAtEq { From 2485b959103de897b4c56ec9990cdee225a963b7 Mon Sep 17 00:00:00 2001 From: davfsa Date: Fri, 2 Jan 2026 23:30:11 +0100 Subject: [PATCH 055/105] feat: improve 429 handling --- lib/queue.go | 20 ++++-- lib/ratelimits.go | 160 ++++++++++++++++++++++++---------------------- 2 files changed, 98 insertions(+), 82 deletions(-) diff --git a/lib/queue.go b/lib/queue.go index 4567a17..67ed0d9 100644 --- a/lib/queue.go +++ b/lib/queue.go @@ -307,6 +307,12 @@ func (item *QueueItem) doRequest(ctx context.Context, q *RequestQueue, ch *Queue } bucket, remaining, limit, resetAfter, resetAt, scope, err := parseHeaders(&resp.Header) + if err != nil { + item.errChan <- err + return + } + + item.doneChan <- resp if scope == "global" { // Lock global @@ -318,21 +324,18 @@ func (item *QueueItem) doRequest(ctx context.Context, q *RequestQueue, ch *Queue "resetAfter": resetAfterDuration, }).Warn("Global reached, locking") } - } - - if err != nil { - item.errChan <- err return } // TODO: Consider handling special retry case for POST /users/@me/channels - item.doneChan <- resp + + ratelimitHit := resp.StatusCode == 429 && scope != "shared" if bucket != "" { - ch.ratelimit.Update(bucket, remaining, limit, resetAt, resetAfter) + ch.ratelimit.Update(bucket, remaining, limit, resetAt, resetAfter, ratelimitHit) } - if resp.StatusCode == 429 && scope != "shared" { + if ratelimitHit { logger.WithFields(logrus.Fields{ "remaining": remaining, "resetAfter": resetAfter, @@ -345,6 +348,7 @@ func (item *QueueItem) doRequest(ctx context.Context, q *RequestQueue, ch *Queue "discordBucket": bucket, "ratelimitScope": scope, }).Warn("Unexpected 429") + return } if resp.StatusCode == 404 && strings.HasPrefix(path, "/webhooks/") && !isInteraction(item.Req.URL.String()) { @@ -357,6 +361,7 @@ func (item *QueueItem) doRequest(ctx context.Context, q *RequestQueue, ch *Queue ch.Lock() ch.lockerFun = return404webhook ch.Unlock() + return } if resp.StatusCode == 401 && !isInteraction(item.Req.URL.String()) && q.queueType != NoAuth { @@ -372,6 +377,7 @@ func (item *QueueItem) doRequest(ctx context.Context, q *RequestQueue, ch *Queue if EnvGet("DISABLE_401_LOCK", "false") != "true" { atomic.StoreInt64(q.isTokenInvalid, 999) } + return } } diff --git a/lib/ratelimits.go b/lib/ratelimits.go index 090e594..a3c4247 100644 --- a/lib/ratelimits.go +++ b/lib/ratelimits.go @@ -23,7 +23,7 @@ func calculateFixedWindow(resetAt, resetAfter float64) (time.Duration, time.Time func calculateSlidingWindow(remaining, limit int64, resetAt, resetAfter float64) (time.Duration, time.Time) { // slidePeriod = resetAfter / (limit - remaining) - slidePeriod := time.Duration(math.Ceil((resetAfter/float64(limit-remaining))*1_000)) * time.Millisecond + slidePeriod := time.Duration((resetAfter/float64(limit-remaining))*1_000) * time.Millisecond // increaseAt = (resetAt - resetAfter) + slidePeriod resetAtTime := time.Unix(0, int64(resetAt*1_000_000_000)) @@ -44,14 +44,17 @@ type BucketRateLimit struct { period time.Duration increaseAt time.Time resetAt float64 + resetAfter float64 inTransitLock sync.Mutex inTransit int64 transitWaitChan chan interface{} - unknown bool - outOfSync bool - fixedWindow bool + unknown bool + outOfSync bool + fixedWindow bool + firstSync bool + ratelimitAvoidance bool } func NewBucketRatelimit(path, identifier string) BucketRateLimit { @@ -72,16 +75,13 @@ func (b *BucketRateLimit) isRatelimited(now time.Time) bool { return false } - // If we are out of sync, we shouldn't slide the window along, as we will be off due to - // network latency. - // The second part of this 'if' is for self-healing reasons, to account for the weird case where - // an error occurs and the bucket is not updated properly, becoming permanently out of sync - if (now.After(b.increaseAt) || now.Equal(b.increaseAt)) && (!b.outOfSync || now.Sub(b.increaseAt) > b.period) { - if b.fixedWindow { - // Fixed windows just reset the remaining back to the limit + if now.After(b.increaseAt) || now.Equal(b.increaseAt) { + if b.fixedWindow || b.ratelimitAvoidance { + // Fixed windows or ratelimit avoidance just reset the remaining back to the limit b.remaining = b.limit b.outOfSync = true b.increaseAt = now.Add(b.period) + b.ratelimitAvoidance = false } else { // We can slide the window along @@ -182,7 +182,35 @@ func (b *BucketRateLimit) Release() { } } -func (b *BucketRateLimit) Update(bucket string, remaining, limit int64, resetAt, resetAfter float64) { +func (b *BucketRateLimit) init(bucket string, remaining, limit int64, resetAt, resetAfter float64) { + b.bucket = bucket + b.resetAt = resetAt + b.resetAfter = resetAfter + b.remaining = remaining + b.limit = limit + b.unknown = false + b.outOfSync = false + b.firstSync = true + b.ratelimitAvoidance = false + + if limit != 1 && remaining == limit-1 { + // We have the perfect condition for a sliding window, so assume that for now. + // Turning it into a fixed bucket later is preferable, as we might never get this chance again + period, increaseAt := calculateSlidingWindow(remaining, limit, resetAt, resetAfter) + b.fixedWindow = false + b.period = period + b.increaseAt = increaseAt + } else { + // We can assume its a fixed bucket for now, and hope that in the future we will get + // the ideal condition + period, increaseAt := calculateFixedWindow(resetAt, resetAfter) + b.fixedWindow = true + b.period = period + b.increaseAt = increaseAt + } +} + +func (b *BucketRateLimit) Update(bucket string, remaining, limit int64, resetAt, resetAfter float64, ratelimitHit bool) { b.lock.Lock() defer b.lock.Unlock() @@ -191,35 +219,52 @@ func (b *BucketRateLimit) Update(bucket string, remaining, limit int64, resetAt, "path": b.path, "identifier": b.identifier, "remaining": remaining, - "limit": remaining, + "limit": limit, "resetAt": resetAt, "resetAfter": resetAfter, + "period": b.period, }).Debug("updating bucket ratelimit") if b.unknown { - period, increaseAt := calculateFixedWindow(resetAt, resetAfter) - - b.bucket = bucket - b.period = period - b.resetAt = resetAt - b.increaseAt = increaseAt - b.remaining = remaining - b.limit = limit - b.outOfSync = false - b.fixedWindow = true - b.unknown = false + b.init(bucket, remaining, limit, resetAt, resetAfter) return } - if resetAt < b.resetAt { + if resetAt-resetAfter < b.resetAt-b.resetAfter { // Old ratelimit information, ignore return } - b.bucket = bucket + if b.bucket != bucket { + logger.WithFields(logrus.Fields{ + "oldBucket": b.bucket, + "newBucket": bucket, + "path": b.path, + "identifier": b.identifier, + "oldLimit": b.limit, + "oldResetAt": b.resetAt, + "oldResetAfter": b.resetAfter, + "newLimit": limit, + "newResetAt": resetAt, + "newResetAfter": resetAfter, + }).Warn("Bucket for route changed. There might be a slight increase in 429s") + + b.init(bucket, remaining, limit, resetAt, resetAfter) + return + } - if !b.outOfSync && remaining < limit - 1 { + if ratelimitHit { + // During ratelimit avoidance, we will treat the bucket as fixed + // bucket and wait for it to fill up completely + b.ratelimitAvoidance = true + b.increaseAt = time.Unix(0, int64(resetAt*1_000_000_000)) + b.remaining = 0 + return + } + + if b.firstSync && remaining > 0 && remaining != limit-1 { resetAtEq := isClose(b.resetAt, resetAt, 0.05) + b.firstSync = false if !b.fixedWindow && resetAtEq { logger.WithFields(logrus.Fields{ @@ -228,7 +273,7 @@ func (b *BucketRateLimit) Update(bucket string, remaining, limit int64, resetAt, "identifier": b.identifier, "storedResetAt": b.resetAt, "receivedResetAt": resetAt, - }).Debug("Bucket detected to be a fixed bucket") + }).Info("Bucket detected to be a fixed bucket") b.fixedWindow = true // Setting this here will have an effect below b.outOfSync = true @@ -240,7 +285,7 @@ func (b *BucketRateLimit) Update(bucket string, remaining, limit int64, resetAt, "identifier": b.identifier, "storedResetAt": b.resetAt, "receivedResetAt": resetAt, - }).Debug("Bucket stopped being a fixed bucket") + }).Debug("Bucket detected to be a sliding bucket") b.fixedWindow = false // Setting this here will have an effect below b.outOfSync = true @@ -248,56 +293,21 @@ func (b *BucketRateLimit) Update(bucket string, remaining, limit int64, resetAt, } b.resetAt = resetAt + b.resetAfter = resetAfter - if b.limit != limit { - if b.limit > limit { - logger.WithFields(logrus.Fields{ - "bucket": b.bucket, - "path": b.path, - "identifier": b.identifier, - "newLimit": limit, - "oldLimit": b.limit, - }).Warn("Bucket decreased its limit. It is possible you will see a small increase in 429s") - } - - b.limit = limit - b.remaining = min(b.remaining, limit) - } - - if b.fixedWindow { - // We want to update the period only, and only if: - // 1. The bucket is out of sync (ie, we reset the full window) - // 2. We receive the first usage of the bucket, which will always have correct period - if b.outOfSync || remaining == limit-1 { - period, increaseAt := calculateFixedWindow(resetAt, resetAfter) - b.period = period - b.increaseAt = increaseAt - - b.outOfSync = false - } + if !b.outOfSync { return } - // We want to update the slide period only, and only if: - // 1. The bucket is out of sync (ie, we reset the full window) - // 2. We receive the first usage of the bucket, which will always have the most accurate slide period - // 3. The slide period increased - // 4. The slide period greatly changed - // Note: 0.3 and 0.5 are chosen arbitrarily after some testing - slidePeriod, increaseAt := calculateSlidingWindow(remaining, limit, resetAt, resetAfter) - if b.outOfSync || remaining == limit-1 || slidePeriod > b.period || !isClose(slidePeriod.Seconds(), b.period.Seconds(), 0.3) { - if !isClose(slidePeriod.Seconds(), b.period.Seconds(), 0.5) { - logger.WithFields(logrus.Fields{ - "bucket": b.bucket, - "path": b.path, - "identifier": b.identifier, - "newSlidePeriod": slidePeriod, - "oldSlidePeriod": b.period, - }).Warn("Bucket greatly changed its slide period. It is possible you will see a small increase in 429s") - } - - b.outOfSync = false - b.period = slidePeriod + if b.fixedWindow { + period, increaseAt := calculateFixedWindow(resetAt, resetAfter) + b.period = period + b.increaseAt = increaseAt + } else { + period, increaseAt := calculateSlidingWindow(remaining, limit, resetAt, resetAfter) + b.period = period b.increaseAt = increaseAt } + + b.outOfSync = false } From 4aa56019f955dd379bbc4ca41595cce76b121384 Mon Sep 17 00:00:00 2001 From: davfsa Date: Fri, 2 Jan 2026 23:31:13 +0100 Subject: [PATCH 056/105] chore: drop logging level --- lib/ratelimits.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ratelimits.go b/lib/ratelimits.go index a3c4247..5feea8f 100644 --- a/lib/ratelimits.go +++ b/lib/ratelimits.go @@ -273,7 +273,7 @@ func (b *BucketRateLimit) Update(bucket string, remaining, limit int64, resetAt, "identifier": b.identifier, "storedResetAt": b.resetAt, "receivedResetAt": resetAt, - }).Info("Bucket detected to be a fixed bucket") + }).Debug("Bucket detected to be a fixed bucket") b.fixedWindow = true // Setting this here will have an effect below b.outOfSync = true From e7645517d07ff39d30c8e888f2bdc2c41bf59af3 Mon Sep 17 00:00:00 2001 From: davfsa Date: Sat, 3 Jan 2026 02:47:29 +0100 Subject: [PATCH 057/105] chore: readd math.Ceil call --- lib/ratelimits.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ratelimits.go b/lib/ratelimits.go index 5feea8f..bbc1ed4 100644 --- a/lib/ratelimits.go +++ b/lib/ratelimits.go @@ -23,7 +23,7 @@ func calculateFixedWindow(resetAt, resetAfter float64) (time.Duration, time.Time func calculateSlidingWindow(remaining, limit int64, resetAt, resetAfter float64) (time.Duration, time.Time) { // slidePeriod = resetAfter / (limit - remaining) - slidePeriod := time.Duration((resetAfter/float64(limit-remaining))*1_000) * time.Millisecond + slidePeriod := time.Duration(math.Ceil((resetAfter/float64(limit-remaining))*1_000)) * time.Millisecond // increaseAt = (resetAt - resetAfter) + slidePeriod resetAtTime := time.Unix(0, int64(resetAt*1_000_000_000)) From 6dec0b6e6b3fee56478716c4f7aa3fb86e5becf2 Mon Sep 17 00:00:00 2001 From: davfsa Date: Sat, 3 Jan 2026 02:58:48 +0100 Subject: [PATCH 058/105] fix: only re-initialize bucket if limit changed --- lib/ratelimits.go | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/lib/ratelimits.go b/lib/ratelimits.go index bbc1ed4..988921f 100644 --- a/lib/ratelimits.go +++ b/lib/ratelimits.go @@ -236,6 +236,24 @@ func (b *BucketRateLimit) Update(bucket string, remaining, limit int64, resetAt, } if b.bucket != bucket { + if (limit != b.limit) { + logger.WithFields(logrus.Fields{ + "oldBucket": b.bucket, + "newBucket": bucket, + "path": b.path, + "identifier": b.identifier, + "oldLimit": b.limit, + "oldResetAt": b.resetAt, + "oldResetAfter": b.resetAfter, + "newLimit": limit, + "newResetAt": resetAt, + "newResetAfter": resetAfter, + }).Warn("Bucket for route changed. There might be a slight increase in 429s") + + b.init(bucket, remaining, limit, resetAt, resetAfter) + return + } + logger.WithFields(logrus.Fields{ "oldBucket": b.bucket, "newBucket": bucket, @@ -247,10 +265,9 @@ func (b *BucketRateLimit) Update(bucket string, remaining, limit int64, resetAt, "newLimit": limit, "newResetAt": resetAt, "newResetAfter": resetAfter, - }).Warn("Bucket for route changed. There might be a slight increase in 429s") + }).Info("Bucket hash changed") - b.init(bucket, remaining, limit, resetAt, resetAfter) - return + b.bucket = bucket } if ratelimitHit { From 10a68afa417e2ba4868499762186cdb69ebfab18 Mon Sep 17 00:00:00 2001 From: davfsa Date: Sat, 3 Jan 2026 03:06:39 +0100 Subject: [PATCH 059/105] chore: remove redundant parenthesis --- lib/ratelimits.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ratelimits.go b/lib/ratelimits.go index 988921f..ec88e0e 100644 --- a/lib/ratelimits.go +++ b/lib/ratelimits.go @@ -236,7 +236,7 @@ func (b *BucketRateLimit) Update(bucket string, remaining, limit int64, resetAt, } if b.bucket != bucket { - if (limit != b.limit) { + if limit != b.limit { logger.WithFields(logrus.Fields{ "oldBucket": b.bucket, "newBucket": bucket, From 81887d5807c8196ff7d09d87597aebfd9bd98c6a Mon Sep 17 00:00:00 2001 From: davfsa Date: Sat, 3 Jan 2026 03:22:08 +0100 Subject: [PATCH 060/105] chore: cleanup logging --- lib/ratelimits.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/ratelimits.go b/lib/ratelimits.go index ec88e0e..35c68b3 100644 --- a/lib/ratelimits.go +++ b/lib/ratelimits.go @@ -248,7 +248,7 @@ func (b *BucketRateLimit) Update(bucket string, remaining, limit int64, resetAt, "newLimit": limit, "newResetAt": resetAt, "newResetAfter": resetAfter, - }).Warn("Bucket for route changed. There might be a slight increase in 429s") + }).Warn("bucket for route changed. There might be a slight increase in 429s") b.init(bucket, remaining, limit, resetAt, resetAfter) return @@ -265,7 +265,7 @@ func (b *BucketRateLimit) Update(bucket string, remaining, limit int64, resetAt, "newLimit": limit, "newResetAt": resetAt, "newResetAfter": resetAfter, - }).Info("Bucket hash changed") + }).Debug("bucket hash changed") b.bucket = bucket } @@ -290,7 +290,7 @@ func (b *BucketRateLimit) Update(bucket string, remaining, limit int64, resetAt, "identifier": b.identifier, "storedResetAt": b.resetAt, "receivedResetAt": resetAt, - }).Debug("Bucket detected to be a fixed bucket") + }).Debug("bucket detected to be a fixed bucket") b.fixedWindow = true // Setting this here will have an effect below b.outOfSync = true @@ -302,7 +302,7 @@ func (b *BucketRateLimit) Update(bucket string, remaining, limit int64, resetAt, "identifier": b.identifier, "storedResetAt": b.resetAt, "receivedResetAt": resetAt, - }).Debug("Bucket detected to be a sliding bucket") + }).Debug("bucket detected to be a sliding bucket") b.fixedWindow = false // Setting this here will have an effect below b.outOfSync = true From 10a554b0d52e022a4aadc118334af236ecda2509 Mon Sep 17 00:00:00 2001 From: davfsa Date: Sat, 3 Jan 2026 11:45:38 +0100 Subject: [PATCH 061/105] chore: re-add self healing logic --- lib/ratelimits.go | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/lib/ratelimits.go b/lib/ratelimits.go index 35c68b3..d89e0a8 100644 --- a/lib/ratelimits.go +++ b/lib/ratelimits.go @@ -75,7 +75,11 @@ func (b *BucketRateLimit) isRatelimited(now time.Time) bool { return false } - if now.After(b.increaseAt) || now.Equal(b.increaseAt) { + // If we are out of sync, we shouldn't slide the window along, as we will be off due to + // network latency. + // The second part of this 'if' is for self-healing purposes, to account for the weird case where + // an error occurs and the bucket is not updated properly, becoming permanently out of sync + if (now.After(b.increaseAt) || now.Equal(b.increaseAt)) && (!b.outOfSync || now.Sub(b.increaseAt) > b.period) { if b.fixedWindow || b.ratelimitAvoidance { // Fixed windows or ratelimit avoidance just reset the remaining back to the limit b.remaining = b.limit @@ -143,6 +147,13 @@ func (b *BucketRateLimit) Acquire(ctx context.Context) error { sleepDuration := b.increaseAt.Sub(now) b.lock.Unlock() + select { + case <-ctx.Done(): + b.Release() + return ctx.Err() + default: + } + if sleepDuration > 0 { logger.WithFields(logrus.Fields{ "bucket": b.bucket, @@ -274,12 +285,14 @@ func (b *BucketRateLimit) Update(bucket string, remaining, limit int64, resetAt, // During ratelimit avoidance, we will treat the bucket as fixed // bucket and wait for it to fill up completely b.ratelimitAvoidance = true - b.increaseAt = time.Unix(0, int64(resetAt*1_000_000_000)) + period, increaseAt := calculateFixedWindow(resetAt, resetAfter) + b.increaseAt = increaseAt + b.period = period b.remaining = 0 return } - if b.firstSync && remaining > 0 && remaining != limit-1 { + if !b.outOfSync && b.firstSync && remaining > 0 && remaining != limit-1 { resetAtEq := isClose(b.resetAt, resetAt, 0.05) b.firstSync = false From f6cd40d589dc8e55609e38bdd16b1539c939212a Mon Sep 17 00:00:00 2001 From: davfsa Date: Sat, 3 Jan 2026 11:55:16 +0100 Subject: [PATCH 062/105] fix: make bucket following less strict for insanely fast connections This can happen due to all Discord responses having a slight margin of error, which makes it likely for ratelimits to be hit if you are *too* quick --- lib/ratelimits.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ratelimits.go b/lib/ratelimits.go index d89e0a8..269e894 100644 --- a/lib/ratelimits.go +++ b/lib/ratelimits.go @@ -79,7 +79,7 @@ func (b *BucketRateLimit) isRatelimited(now time.Time) bool { // network latency. // The second part of this 'if' is for self-healing purposes, to account for the weird case where // an error occurs and the bucket is not updated properly, becoming permanently out of sync - if (now.After(b.increaseAt) || now.Equal(b.increaseAt)) && (!b.outOfSync || now.Sub(b.increaseAt) > b.period) { + if now.After(b.increaseAt) && (!b.outOfSync || now.Sub(b.increaseAt) > b.period) { if b.fixedWindow || b.ratelimitAvoidance { // Fixed windows or ratelimit avoidance just reset the remaining back to the limit b.remaining = b.limit From d79fade2aa1920b767f206763d7bd50d01d0fc62 Mon Sep 17 00:00:00 2001 From: davfsa Date: Sat, 3 Jan 2026 14:42:44 +0100 Subject: [PATCH 063/105] chore: remove random spaces --- lib/ratelimits.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ratelimits.go b/lib/ratelimits.go index 269e894..169ce8c 100644 --- a/lib/ratelimits.go +++ b/lib/ratelimits.go @@ -264,7 +264,7 @@ func (b *BucketRateLimit) Update(bucket string, remaining, limit int64, resetAt, b.init(bucket, remaining, limit, resetAt, resetAfter) return } - + logger.WithFields(logrus.Fields{ "oldBucket": b.bucket, "newBucket": bucket, From 0d7c14e8663225d97e7027c78ec14e5cf4746800 Mon Sep 17 00:00:00 2001 From: davfsa Date: Sat, 3 Jan 2026 16:41:37 +0100 Subject: [PATCH 064/105] chore: remove incorrect optimistic bucket path --- lib/bucketpath.go | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/lib/bucketpath.go b/lib/bucketpath.go index 9a0062c..649a5f2 100644 --- a/lib/bucketpath.go +++ b/lib/bucketpath.go @@ -8,11 +8,11 @@ import ( ) const ( - MajorUnknown = "unk" - MajorChannels = "channels" - MajorGuilds = "guilds" - MajorWebhooks = "webhooks" - MajorInvites = "invites" + MajorUnknown = "unk" + MajorChannels = "channels" + MajorGuilds = "guilds" + MajorWebhooks = "webhooks" + MajorInvites = "invites" MajorInteractions = "interactions" ) @@ -48,7 +48,9 @@ func GetMetricsPath(route string) string { } for _, part := range parts { - if part == "" { continue } + if part == "" { + continue + } if IsNumericInput(part) { path += "/!" } else { @@ -72,7 +74,7 @@ func GetOptimisticBucketPath(url string, method string) string { cleanUrl = strings.ReplaceAll(cleanUrl, "/api/v", "") l := len(cleanUrl) i := strings.Index(cleanUrl, "/") - cleanUrl = cleanUrl[i+1:l] + cleanUrl = cleanUrl[i+1 : l] } else { // Handle unversioned endpoints cleanUrl = strings.ReplaceAll(cleanUrl, "/api/", "") @@ -105,10 +107,6 @@ func GetOptimisticBucketPath(url string, method string) string { bucket.WriteString("/!") currMajor = MajorInvites case MajorGuilds: - // guilds/:guildId/channels share the same bucket for all guilds - if numParts == 3 && parts[2] == "channels" { - return "/" + MajorGuilds + "/!/channels" - } fallthrough case MajorInteractions: if numParts == 4 && parts[3] == "callback" { @@ -133,7 +131,7 @@ func GetOptimisticBucketPath(url string, method string) string { for idx, part := range parts[2:] { if IsSnowflake(part) { // Custom rule for messages older than 14d - if currMajor == MajorChannels && parts[idx - 1] == "messages" && method == "DELETE" { + if currMajor == MajorChannels && parts[idx-1] == "messages" && method == "DELETE" { createdAt, _ := GetSnowflakeCreatedAt(part) if createdAt.Before(time.Now().Add(-1 * 14 * 24 * time.Hour)) { bucket.WriteString("/!14dmsg") @@ -178,13 +176,12 @@ func GetOptimisticBucketPath(url string, method string) string { } else { interactionId = strings.Split(string(decodedPart), ":")[1] } - + bucket.WriteByte('/') bucket.WriteString(interactionId) continue } - // Strip webhook tokens and interaction tokens if (currMajor == MajorWebhooks || currMajor == MajorInteractions) && len(part) >= 64 { bucket.WriteString("/!") @@ -196,4 +193,4 @@ func GetOptimisticBucketPath(url string, method string) string { } return bucket.String() -} \ No newline at end of file +} From cadbd6f22d5857d9b601df78aba147260c9977cc Mon Sep 17 00:00:00 2001 From: davfsa Date: Sat, 3 Jan 2026 18:36:49 +0100 Subject: [PATCH 065/105] chore: remove sliding prevention if out of sync --- lib/ratelimits.go | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/lib/ratelimits.go b/lib/ratelimits.go index 169ce8c..3ee8e12 100644 --- a/lib/ratelimits.go +++ b/lib/ratelimits.go @@ -75,11 +75,7 @@ func (b *BucketRateLimit) isRatelimited(now time.Time) bool { return false } - // If we are out of sync, we shouldn't slide the window along, as we will be off due to - // network latency. - // The second part of this 'if' is for self-healing purposes, to account for the weird case where - // an error occurs and the bucket is not updated properly, becoming permanently out of sync - if now.After(b.increaseAt) && (!b.outOfSync || now.Sub(b.increaseAt) > b.period) { + if now.After(b.increaseAt) { if b.fixedWindow || b.ratelimitAvoidance { // Fixed windows or ratelimit avoidance just reset the remaining back to the limit b.remaining = b.limit @@ -285,9 +281,8 @@ func (b *BucketRateLimit) Update(bucket string, remaining, limit int64, resetAt, // During ratelimit avoidance, we will treat the bucket as fixed // bucket and wait for it to fill up completely b.ratelimitAvoidance = true - period, increaseAt := calculateFixedWindow(resetAt, resetAfter) + _, increaseAt := calculateFixedWindow(resetAt, resetAfter) b.increaseAt = increaseAt - b.period = period b.remaining = 0 return } From b4da5664483aba686a09f40c6db22d32acd35665 Mon Sep 17 00:00:00 2001 From: davfsa Date: Sat, 3 Jan 2026 18:39:00 +0100 Subject: [PATCH 066/105] chore: re-add after or equal check --- lib/ratelimits.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ratelimits.go b/lib/ratelimits.go index 3ee8e12..b3cd30c 100644 --- a/lib/ratelimits.go +++ b/lib/ratelimits.go @@ -75,7 +75,7 @@ func (b *BucketRateLimit) isRatelimited(now time.Time) bool { return false } - if now.After(b.increaseAt) { + if now.After(b.increaseAt) || now.Equal(b.increaseAt) { if b.fixedWindow || b.ratelimitAvoidance { // Fixed windows or ratelimit avoidance just reset the remaining back to the limit b.remaining = b.limit From c6b4f8aafb4ffa0328b2bb7b25f4e270a389d32f Mon Sep 17 00:00:00 2001 From: davfsa Date: Sun, 4 Jan 2026 12:35:00 +0100 Subject: [PATCH 067/105] chore: speedup arm docker builds Signed-off-by: davfsa --- Dockerfile | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index c35da2e..3d4a0b9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,11 @@ -FROM golang:alpine as app-builder +FROM --platform=$BUILDPLATFORM golang:alpine as app-builder WORKDIR /go/src/app COPY . . -RUN CGO_ENABLED=0 go install -ldflags '-extldflags "-static"' -tags timetzdata -buildvcs=false + +ARG TARGETOS +ARG TARGETARCH + +RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go install -ldflags '-extldflags "-static"' -tags timetzdata -buildvcs=false FROM scratch COPY --from=app-builder /go/bin/nirn-proxy /nirn-proxy @@ -10,4 +14,4 @@ COPY --from=app-builder /go/bin/nirn-proxy /nirn-proxy COPY --from=alpine:latest /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ EXPOSE 9000 EXPOSE 8080 -ENTRYPOINT ["/nirn-proxy"] \ No newline at end of file +ENTRYPOINT ["/nirn-proxy"] From 2e461d5fbb7e36810a6aa5230b6495145d72a460 Mon Sep 17 00:00:00 2001 From: davfsa Date: Sun, 4 Jan 2026 12:36:11 +0100 Subject: [PATCH 068/105] chore: also build docker images on pushes to branches Signed-off-by: davfsa --- .github/workflows/docker-publish-tags.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker-publish-tags.yml b/.github/workflows/docker-publish-tags.yml index c740c2b..4381f3a 100644 --- a/.github/workflows/docker-publish-tags.yml +++ b/.github/workflows/docker-publish-tags.yml @@ -7,11 +7,11 @@ name: Docker on: push: - branches: [ main, dev ] + branches: [ main, dev, sliding-window-ratelimiter ] # Publish semver tags as releases. tags: [ 'v*.*.*' ] pull_request: - branches: [ main ] + branches: [ main, sliding-window-ratelimiter ] env: # Use docker.io for Docker Hub if empty From 6a1571d0aaee20cb3da1929b709d07ce8db443f8 Mon Sep 17 00:00:00 2001 From: davfsa Date: Sun, 4 Jan 2026 12:42:36 +0100 Subject: [PATCH 069/105] fix: go build instead of go install Signed-off-by: davfsa --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 3d4a0b9..f32356a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,10 +5,10 @@ COPY . . ARG TARGETOS ARG TARGETARCH -RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go install -ldflags '-extldflags "-static"' -tags timetzdata -buildvcs=false +RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags '-extldflags "-static"' -tags timetzdata -buildvcs=false FROM scratch -COPY --from=app-builder /go/bin/nirn-proxy /nirn-proxy +COPY --from=app-builder /go/src/app/nirn-proxy /nirn-proxy # the tls certificates: # NB: this pulls directly from the upstream image, which already has ca-certificates: COPY --from=alpine:latest /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ From 7e94515041e3f6bfbc4f03afbece951f372efaf0 Mon Sep 17 00:00:00 2001 From: davfsa Date: Sun, 4 Jan 2026 12:45:36 +0100 Subject: [PATCH 070/105] chore: re-disable ci in testing branches Signed-off-by: davfsa --- .github/workflows/docker-publish-tags.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker-publish-tags.yml b/.github/workflows/docker-publish-tags.yml index 4381f3a..c740c2b 100644 --- a/.github/workflows/docker-publish-tags.yml +++ b/.github/workflows/docker-publish-tags.yml @@ -7,11 +7,11 @@ name: Docker on: push: - branches: [ main, dev, sliding-window-ratelimiter ] + branches: [ main, dev ] # Publish semver tags as releases. tags: [ 'v*.*.*' ] pull_request: - branches: [ main, sliding-window-ratelimiter ] + branches: [ main ] env: # Use docker.io for Docker Hub if empty From 70e506a7ca2dc1f34fc390f2f85c0310f548a7f5 Mon Sep 17 00:00:00 2001 From: davfsa Date: Fri, 9 Jan 2026 12:49:23 +0100 Subject: [PATCH 071/105] fix: properly calculate message delete bucket path --- lib/bucketpath.go | 16 +++++++++++----- lib/bucketpath_test.go | 26 ++++++++++++++++++++------ 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/lib/bucketpath.go b/lib/bucketpath.go index 649a5f2..901be3a 100644 --- a/lib/bucketpath.go +++ b/lib/bucketpath.go @@ -128,15 +128,21 @@ func GetOptimisticBucketPath(url string, method string) string { // At this point, the major + id part is already accounted for // In this loop, we only need to strip all remaining snowflakes, emoji names and webhook tokens(optional) - for idx, part := range parts[2:] { + parts = parts[2:] + + for idx, part := range parts { if IsSnowflake(part) { - // Custom rule for messages older than 14d - if currMajor == MajorChannels && parts[idx-1] == "messages" && method == "DELETE" { + //Custom rule for message DELETES older than 14d + if currMajor == MajorChannels && idx == len(parts)-1 && parts[idx-1] == "messages" && method == "DELETE" { createdAt, _ := GetSnowflakeCreatedAt(part) - if createdAt.Before(time.Now().Add(-1 * 14 * 24 * time.Hour)) { + diff := time.Now().Sub(createdAt) + + if diff >= 14*24*time.Hour { bucket.WriteString("/!14dmsg") - } else if createdAt.After(time.Now().Add(-1 * 10 * time.Second)) { + } else if diff < 10*time.Second { bucket.WriteString("/!10smsg") + } else { + bucket.WriteString("/!") } continue } diff --git a/lib/bucketpath_test.go b/lib/bucketpath_test.go index dee751d..677e0e0 100644 --- a/lib/bucketpath_test.go +++ b/lib/bucketpath_test.go @@ -3,6 +3,8 @@ package lib import ( "fmt" "testing" + "testing/synctest" + "time" ) func TestPaths(t *testing.T) { @@ -32,8 +34,6 @@ func TestPaths(t *testing.T) { // No known major {"/api/v9/invalid/203039963636301824", "GET", "/invalid/203039963636301824"}, {"/api/v9/invalid/203039963636301824/route/203039963636301824", "GET", "/invalid/203039963636301824/route/!"}, - //Special case for /guilds/:id/channels - {"/api/v9/guilds/203039963636301824/channels", "GET", "/guilds/!/channels"}, // Wierd routes {"/api/v9/guilds/templates/203039963636301824", "GET", "/guilds/templates/!"}, // Unversioned routes @@ -44,14 +44,28 @@ func TestPaths(t *testing.T) { // Application commands {"/api/v9/applications/203039963636301824/commands", "GET", "/applications/203039963636301824/commands"}, {"/api/v9/applications/203039963636301824/commands/203039963636301824", "GET", "/applications/203039963636301824/commands/!"}, + // Message delete has multiple buckets + // exactly at 2016-01-01 00:00:00 + {"/api/v9/channels/1412822759695974551/messages/132271570957574145", "DELETE", "/channels/1412822759695974551/messages/!10smsg"}, + // 10 seconds after 2016-01-01 00:00:00 + {"/api/v9/channels/1412822759695974551/messages/132271529014534145", "DELETE", "/channels/1412822759695974551/messages/!"}, + // 14 days before 2016-01-01 00:00:00 + {"/api/v9/channels/1412822759695974551/messages/127198140839174145", "DELETE", "/channels/1412822759695974551/messages/!14dmsg"}, } for _, tt := range tests { testname := fmt.Sprintf("%s-%s", tt.method, tt.path) t.Run(testname, func(t *testing.T) { - bucket := GetOptimisticBucketPath(tt.path, tt.method) - if bucket != tt.want { - t.Errorf("Expected %s but got %s", tt.want, bucket) - } + // Time will start at UTC 2000-01-01 00:00:00 + synctest.Test(t, func(t *testing.T) { + // 16 years in hours + time.Sleep(140256 * time.Hour) + + // Time will always be midnight UTC 2016-01-01 00:00:00 + bucket := GetOptimisticBucketPath(tt.path, tt.method) + if bucket != tt.want { + t.Errorf("Expected %s but got %s", tt.want, bucket) + } + }) }) } } From d27c34b7719b5746b0621b59e4a839904ba44f91 Mon Sep 17 00:00:00 2001 From: davfsa Date: Fri, 9 Jan 2026 12:49:49 +0100 Subject: [PATCH 072/105] feat: add more logging to buckets --- lib/queue.go | 6 +++--- lib/ratelimits.go | 29 ++++++++++++++++------------- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/lib/queue.go b/lib/queue.go index 67ed0d9..fc82aa6 100644 --- a/lib/queue.go +++ b/lib/queue.go @@ -169,7 +169,7 @@ func (q *RequestQueue) Queue(req *http.Request, res *http.ResponseWriter, path s "method": req.Method, }).Trace("Inbound request") - ch := q.getQueueChannel(path, pathHash) + ch := q.getQueueChannel(path, req.Method, pathHash) doneChan := make(chan *http.Response) errChan := make(chan error) @@ -184,7 +184,7 @@ func (q *RequestQueue) Queue(req *http.Request, res *http.ResponseWriter, path s } } -func (q *RequestQueue) getQueueChannel(path string, pathHash uint64) *QueueChannel { +func (q *RequestQueue) getQueueChannel(path, method string, pathHash uint64) *QueueChannel { t := time.Now() q.Lock() defer q.Unlock() @@ -193,7 +193,7 @@ func (q *RequestQueue) getQueueChannel(path string, pathHash uint64) *QueueChann ch = &QueueChannel{ ch: make(chan *QueueItem, q.bufferSize), lastUsed: t, - ratelimit: NewBucketRatelimit(path, q.identifier), + ratelimit: NewBucketRatelimit(path, method, q.identifier), } q.queues[pathHash] = ch // It's important that we only have 1 goroutine per channel diff --git a/lib/ratelimits.go b/lib/ratelimits.go index b3cd30c..6e44749 100644 --- a/lib/ratelimits.go +++ b/lib/ratelimits.go @@ -36,6 +36,7 @@ func calculateSlidingWindow(remaining, limit int64, resetAt, resetAfter float64) // BucketRateLimit is a sliding window ratelimit implementation type BucketRateLimit struct { identifier string + method string path string bucket string lock sync.Mutex @@ -57,9 +58,10 @@ type BucketRateLimit struct { ratelimitAvoidance bool } -func NewBucketRatelimit(path, identifier string) BucketRateLimit { +func NewBucketRatelimit(path, method, identifier string) BucketRateLimit { return BucketRateLimit{ path: path, + method: method, identifier: identifier, limit: 1, unknown: true, @@ -222,15 +224,16 @@ func (b *BucketRateLimit) Update(bucket string, remaining, limit int64, resetAt, defer b.lock.Unlock() logger.WithFields(logrus.Fields{ - "bucket": b.bucket, + "bucket": bucket, "path": b.path, + "method": b.method, "identifier": b.identifier, "remaining": remaining, "limit": limit, "resetAt": resetAt, "resetAfter": resetAfter, "period": b.period, - }).Debug("updating bucket ratelimit") + }).Info("updating bucket ratelimit") if b.unknown { b.init(bucket, remaining, limit, resetAt, resetAfter) @@ -242,6 +245,16 @@ func (b *BucketRateLimit) Update(bucket string, remaining, limit int64, resetAt, return } + if ratelimitHit { + // During ratelimit avoidance, we will treat the bucket as fixed + // bucket and wait for it to fill up completely + b.ratelimitAvoidance = true + _, increaseAt := calculateFixedWindow(resetAt, resetAfter) + b.increaseAt = increaseAt + b.remaining = 0 + return + } + if b.bucket != bucket { if limit != b.limit { logger.WithFields(logrus.Fields{ @@ -277,16 +290,6 @@ func (b *BucketRateLimit) Update(bucket string, remaining, limit int64, resetAt, b.bucket = bucket } - if ratelimitHit { - // During ratelimit avoidance, we will treat the bucket as fixed - // bucket and wait for it to fill up completely - b.ratelimitAvoidance = true - _, increaseAt := calculateFixedWindow(resetAt, resetAfter) - b.increaseAt = increaseAt - b.remaining = 0 - return - } - if !b.outOfSync && b.firstSync && remaining > 0 && remaining != limit-1 { resetAtEq := isClose(b.resetAt, resetAt, 0.05) b.firstSync = false From 0ee3b4f9f44e544b3a028c78169d30c241945469 Mon Sep 17 00:00:00 2001 From: davfsa Date: Fri, 9 Jan 2026 13:16:04 +0100 Subject: [PATCH 073/105] fix: properly handle route scoped ratelimit hits --- lib/queue.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/queue.go b/lib/queue.go index fc82aa6..6f8b516 100644 --- a/lib/queue.go +++ b/lib/queue.go @@ -329,13 +329,13 @@ func (item *QueueItem) doRequest(ctx context.Context, q *RequestQueue, ch *Queue // TODO: Consider handling special retry case for POST /users/@me/channels - ratelimitHit := resp.StatusCode == 429 && scope != "shared" + ratelimitHit := resp.StatusCode == 429 - if bucket != "" { + if bucket != "" || ratelimitHit { ch.ratelimit.Update(bucket, remaining, limit, resetAt, resetAfter, ratelimitHit) } - if ratelimitHit { + if ratelimitHit && scope != "shared" { logger.WithFields(logrus.Fields{ "remaining": remaining, "resetAfter": resetAfter, From 0ab9d204fe03004a4a0c98c2c74cf2366816103f Mon Sep 17 00:00:00 2001 From: davfsa Date: Fri, 9 Jan 2026 17:36:15 +0100 Subject: [PATCH 074/105] fix: drop logger back to debug --- lib/ratelimits.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ratelimits.go b/lib/ratelimits.go index 6e44749..0eb8e63 100644 --- a/lib/ratelimits.go +++ b/lib/ratelimits.go @@ -233,7 +233,7 @@ func (b *BucketRateLimit) Update(bucket string, remaining, limit int64, resetAt, "resetAt": resetAt, "resetAfter": resetAfter, "period": b.period, - }).Info("updating bucket ratelimit") + }).Debug("updating bucket ratelimit") if b.unknown { b.init(bucket, remaining, limit, resetAt, resetAfter) From 928030d4dba465cbd97108b3019b25ade856aa5d Mon Sep 17 00:00:00 2001 From: davfsa Date: Fri, 9 Jan 2026 23:50:54 +0100 Subject: [PATCH 075/105] feat: multi-bucket handling (#1) --- lib/{ratelimits.go => bucket.go} | 220 ++++++++++----------------- lib/bucketpath.go | 82 +++++----- lib/bucketpath_test.go | 2 - lib/distributed_global.go | 14 +- lib/queue.go | 251 ++++++++++++++++++++++++++----- lib/queue_manager.go | 15 +- 6 files changed, 344 insertions(+), 240 deletions(-) rename lib/{ratelimits.go => bucket.go} (59%) diff --git a/lib/ratelimits.go b/lib/bucket.go similarity index 59% rename from lib/ratelimits.go rename to lib/bucket.go index 0eb8e63..4f1f90c 100644 --- a/lib/ratelimits.go +++ b/lib/bucket.go @@ -33,51 +33,66 @@ func calculateSlidingWindow(remaining, limit int64, resetAt, resetAfter float64) return slidePeriod, increaseAt } -// BucketRateLimit is a sliding window ratelimit implementation -type BucketRateLimit struct { - identifier string - method string - path string - bucket string - lock sync.Mutex - remaining int64 - limit int64 - period time.Duration - increaseAt time.Time - resetAt float64 - resetAfter float64 - - inTransitLock sync.Mutex - inTransit int64 +// Bucket is a Discord bucket ratelimiter +type Bucket struct { + increaseAt time.Time + serverUpdateAt time.Time transitWaitChan chan interface{} - unknown bool + bucket string + remaining int64 + limit int64 + period time.Duration + resetAt float64 + inTransit int64 + + stateLock sync.Mutex + inTransitLock sync.Mutex + acquireLock sync.Mutex + outOfSync bool fixedWindow bool - firstSync bool + firstSeen bool ratelimitAvoidance bool } -func NewBucketRatelimit(path, method, identifier string) BucketRateLimit { - return BucketRateLimit{ - path: path, - method: method, - identifier: identifier, - limit: 1, - unknown: true, +func NewBucket(bucket string, remaining, limit int64, resetAt, resetAfter float64) *Bucket { + var period time.Duration + var increaseAt time.Time + var fixedWindow bool + + if limit != 1 && remaining == limit-1 { + // We have the perfect condition for a sliding window, so assume that for now. + // Turning it into a fixed bucket later is preferable, as we might never get this chance again + period, increaseAt = calculateSlidingWindow(remaining, limit, resetAt, resetAfter) + fixedWindow = false + } else { + // We can assume its a fixed bucket for now, and hope that in the future we will get + // the ideal condition + period, increaseAt = calculateFixedWindow(resetAt, resetAfter) + fixedWindow = true } -} -// Warning: this MUST be called from a locked state -func (b *BucketRateLimit) isRatelimited(now time.Time) bool { - if b.unknown { - // Don't do any waiting logic as we don't have any information on the bucket, - // just do the request immediately. The first successful request will return the bucket - // data - return false + return &Bucket{ + bucket: bucket, + remaining: remaining, + limit: limit, + resetAt: resetAt, + period: period, + increaseAt: increaseAt, + fixedWindow: fixedWindow, + firstSeen: true, } +} - if now.After(b.increaseAt) || now.Equal(b.increaseAt) { +// Warning: this MUST be called from a locked state +func (b *Bucket) isRatelimited(now time.Time) bool { + // If we are out of sync, we shouldn't slide the window along, as we will be off due to + // network latency. + // + // The second part of this 'if' is for self-healing purposes, to account for the weird case where + // an error occurs and the bucket is not updated properly, becoming permanently out of sync + if now.After(b.increaseAt) && (!b.outOfSync || now.Sub(b.increaseAt) > b.period) { if b.fixedWindow || b.ratelimitAvoidance { // Fixed windows or ratelimit avoidance just reset the remaining back to the limit b.remaining = b.limit @@ -106,9 +121,10 @@ func (b *BucketRateLimit) isRatelimited(now time.Time) bool { return b.remaining <= 0 } -// Acquire will request a slot from the ratelimit and sleep until there is one available -// NOTE: This function does not support concurrent calls! -func (b *BucketRateLimit) Acquire(ctx context.Context) error { +// Acquire will request a slot from the ratelimit or sleep until there is one available +func (b *Bucket) Acquire(ctx context.Context) error { + b.acquireLock.Lock() + defer b.acquireLock.Unlock() b.inTransitLock.Lock() if b.inTransit >= b.limit { // Buffer of 1 here to prevent deadlocks in a worst case scenario @@ -136,14 +152,14 @@ func (b *BucketRateLimit) Acquire(ctx context.Context) error { } for { - b.lock.Lock() + b.stateLock.Lock() now := time.Now() if !b.isRatelimited(now) { // b.lock will be unlocked after decrementing remaining break } sleepDuration := b.increaseAt.Sub(now) - b.lock.Unlock() + b.stateLock.Unlock() select { case <-ctx.Done(): @@ -155,8 +171,6 @@ func (b *BucketRateLimit) Acquire(ctx context.Context) error { if sleepDuration > 0 { logger.WithFields(logrus.Fields{ "bucket": b.bucket, - "path": b.path, - "identifier": b.identifier, "sleepDuration": sleepDuration, }).Debug("backing off to avoid hitting ratelimits") @@ -169,12 +183,13 @@ func (b *BucketRateLimit) Acquire(ctx context.Context) error { } } + // FIXME: Consider not decrementing remaining until release and make sure the request was made b.remaining-- - b.lock.Unlock() + b.stateLock.Unlock() return nil } -func (b *BucketRateLimit) Release() { +func (b *Bucket) Release() { b.inTransitLock.Lock() defer b.inTransitLock.Unlock() @@ -191,114 +206,28 @@ func (b *BucketRateLimit) Release() { } } -func (b *BucketRateLimit) init(bucket string, remaining, limit int64, resetAt, resetAfter float64) { - b.bucket = bucket - b.resetAt = resetAt - b.resetAfter = resetAfter - b.remaining = remaining - b.limit = limit - b.unknown = false - b.outOfSync = false - b.firstSync = true - b.ratelimitAvoidance = false - - if limit != 1 && remaining == limit-1 { - // We have the perfect condition for a sliding window, so assume that for now. - // Turning it into a fixed bucket later is preferable, as we might never get this chance again - period, increaseAt := calculateSlidingWindow(remaining, limit, resetAt, resetAfter) - b.fixedWindow = false - b.period = period - b.increaseAt = increaseAt - } else { - // We can assume its a fixed bucket for now, and hope that in the future we will get - // the ideal condition - period, increaseAt := calculateFixedWindow(resetAt, resetAfter) - b.fixedWindow = true - b.period = period - b.increaseAt = increaseAt - } -} +func (b *Bucket) Update(remaining, limit int64, resetAt, resetAfter float64, ratelimitHit bool) { + resetAtTime := time.Unix(0, int64(resetAt*1_000_000_000)) + resetAfterDuration := time.Duration(resetAfter*1_000) * time.Millisecond + serverUpdateAt := resetAtTime.Add(-resetAfterDuration) -func (b *BucketRateLimit) Update(bucket string, remaining, limit int64, resetAt, resetAfter float64, ratelimitHit bool) { - b.lock.Lock() - defer b.lock.Unlock() - - logger.WithFields(logrus.Fields{ - "bucket": bucket, - "path": b.path, - "method": b.method, - "identifier": b.identifier, - "remaining": remaining, - "limit": limit, - "resetAt": resetAt, - "resetAfter": resetAfter, - "period": b.period, - }).Debug("updating bucket ratelimit") - - if b.unknown { - b.init(bucket, remaining, limit, resetAt, resetAfter) - return - } + b.stateLock.Lock() + defer b.stateLock.Unlock() - if resetAt-resetAfter < b.resetAt-b.resetAfter { + if serverUpdateAt.Before(b.serverUpdateAt) { // Old ratelimit information, ignore return } - if ratelimitHit { - // During ratelimit avoidance, we will treat the bucket as fixed - // bucket and wait for it to fill up completely - b.ratelimitAvoidance = true - _, increaseAt := calculateFixedWindow(resetAt, resetAfter) - b.increaseAt = increaseAt - b.remaining = 0 - return - } + b.serverUpdateAt = serverUpdateAt - if b.bucket != bucket { - if limit != b.limit { - logger.WithFields(logrus.Fields{ - "oldBucket": b.bucket, - "newBucket": bucket, - "path": b.path, - "identifier": b.identifier, - "oldLimit": b.limit, - "oldResetAt": b.resetAt, - "oldResetAfter": b.resetAfter, - "newLimit": limit, - "newResetAt": resetAt, - "newResetAfter": resetAfter, - }).Warn("bucket for route changed. There might be a slight increase in 429s") - - b.init(bucket, remaining, limit, resetAt, resetAfter) - return - } - - logger.WithFields(logrus.Fields{ - "oldBucket": b.bucket, - "newBucket": bucket, - "path": b.path, - "identifier": b.identifier, - "oldLimit": b.limit, - "oldResetAt": b.resetAt, - "oldResetAfter": b.resetAfter, - "newLimit": limit, - "newResetAt": resetAt, - "newResetAfter": resetAfter, - }).Debug("bucket hash changed") - - b.bucket = bucket - } - - if !b.outOfSync && b.firstSync && remaining > 0 && remaining != limit-1 { + if b.firstSeen && !b.outOfSync && remaining > 0 && remaining != limit-1 { resetAtEq := isClose(b.resetAt, resetAt, 0.05) - b.firstSync = false + b.firstSeen = false if !b.fixedWindow && resetAtEq { logger.WithFields(logrus.Fields{ "bucket": b.bucket, - "path": b.path, - "identifier": b.identifier, "storedResetAt": b.resetAt, "receivedResetAt": resetAt, }).Debug("bucket detected to be a fixed bucket") @@ -309,8 +238,6 @@ func (b *BucketRateLimit) Update(bucket string, remaining, limit int64, resetAt, } else if b.fixedWindow && !resetAtEq { logger.WithFields(logrus.Fields{ "bucket": b.bucket, - "path": b.path, - "identifier": b.identifier, "storedResetAt": b.resetAt, "receivedResetAt": resetAt, }).Debug("bucket detected to be a sliding bucket") @@ -321,7 +248,16 @@ func (b *BucketRateLimit) Update(bucket string, remaining, limit int64, resetAt, } b.resetAt = resetAt - b.resetAfter = resetAfter + + if ratelimitHit { + // During ratelimit avoidance, we will treat the bucket as fixed + // bucket and wait for it to fill up completely + b.ratelimitAvoidance = true + _, increaseAt := calculateFixedWindow(resetAt, resetAfter) + b.increaseAt = increaseAt + b.remaining = 0 + return + } if !b.outOfSync { return diff --git a/lib/bucketpath.go b/lib/bucketpath.go index 901be3a..04e06f8 100644 --- a/lib/bucketpath.go +++ b/lib/bucketpath.go @@ -139,63 +139,63 @@ func GetOptimisticBucketPath(url string, method string) string { if diff >= 14*24*time.Hour { bucket.WriteString("/!14dmsg") - } else if diff < 10*time.Second { - bucket.WriteString("/!10smsg") } else { bucket.WriteString("/!") } continue } + bucket.WriteString("/!") - } else { - if currMajor == MajorChannels && part == "reactions" { - // reaction put/delete fall under a different bucket from other reaction endpoints - if method == "PUT" || method == "DELETE" { - bucket.WriteString("/reactions/!modify") - break - } - //All other reaction endpoints falls under the same bucket, so it's irrelevant if the user - //is passing userid, emoji, etc. - bucket.WriteString("/reactions/!/!") - //Reactions can only be followed by emoji/userid combo, since we don't care, break + continue + } + + if currMajor == MajorChannels && part == "reactions" { + // reaction put/delete fall under a different bucket from other reaction endpoints + if method == "PUT" || method == "DELETE" { + bucket.WriteString("/reactions/!modify") break } + //All other reaction endpoints falls under the same bucket, so it's irrelevant if the user + //is passing userid, emoji, etc. + bucket.WriteString("/reactions/!/!") + //Reactions can only be followed by emoji/userid combo, since we don't care, break + break + } - // Strip webhook tokens, or extract interaction ID - if len(part) >= 64 { - // aW50ZXJhY3Rpb246 is base64 for "interaction:" - if !strings.HasPrefix(part, "aW50ZXJhY3Rpb246") { - bucket.WriteString("/!") - continue - } - - var interactionId string - - // fix padding - if i := len(part) % 4; i != 0 { - part += strings.Repeat("=", 4-i) - } + // Strip webhook tokens, or extract interaction ID + if len(part) >= 64 { + // aW50ZXJhY3Rpb246 is base64 for "interaction:" + if !strings.HasPrefix(part, "aW50ZXJhY3Rpb246") { + bucket.WriteString("/!") + continue + } - decodedPart, err := base64.StdEncoding.DecodeString(part) - if err != nil { - interactionId = "Unknown" - } else { - interactionId = strings.Split(string(decodedPart), ":")[1] - } + var interactionId string - bucket.WriteByte('/') - bucket.WriteString(interactionId) - continue + // fix padding + if i := len(part) % 4; i != 0 { + part += strings.Repeat("=", 4-i) } - // Strip webhook tokens and interaction tokens - if (currMajor == MajorWebhooks || currMajor == MajorInteractions) && len(part) >= 64 { - bucket.WriteString("/!") - continue + decodedPart, err := base64.StdEncoding.DecodeString(part) + if err != nil { + interactionId = "Unknown" + } else { + interactionId = strings.Split(string(decodedPart), ":")[1] } + bucket.WriteByte('/') - bucket.WriteString(part) + bucket.WriteString(interactionId) + continue + } + + // Strip webhook tokens and interaction tokens + if (currMajor == MajorWebhooks || currMajor == MajorInteractions) && len(part) >= 64 { + bucket.WriteString("/!") + continue } + bucket.WriteByte('/') + bucket.WriteString(part) } return bucket.String() diff --git a/lib/bucketpath_test.go b/lib/bucketpath_test.go index 677e0e0..92a15c7 100644 --- a/lib/bucketpath_test.go +++ b/lib/bucketpath_test.go @@ -45,8 +45,6 @@ func TestPaths(t *testing.T) { {"/api/v9/applications/203039963636301824/commands", "GET", "/applications/203039963636301824/commands"}, {"/api/v9/applications/203039963636301824/commands/203039963636301824", "GET", "/applications/203039963636301824/commands/!"}, // Message delete has multiple buckets - // exactly at 2016-01-01 00:00:00 - {"/api/v9/channels/1412822759695974551/messages/132271570957574145", "DELETE", "/channels/1412822759695974551/messages/!10smsg"}, // 10 seconds after 2016-01-01 00:00:00 {"/api/v9/channels/1412822759695974551/messages/132271529014534145", "DELETE", "/channels/1412822759695974551/messages/!"}, // 14 days before 2016-01-01 00:00:00 diff --git a/lib/distributed_global.go b/lib/distributed_global.go index b1610cc..d0cf56c 100644 --- a/lib/distributed_global.go +++ b/lib/distributed_global.go @@ -13,15 +13,15 @@ import ( ) type ClusterGlobalRateLimiter struct { - sync.RWMutex globalBucketsMap map[uint64]*leakybucket.Bucket - memStorage *memory.Storage + memStorage *memory.Storage + sync.RWMutex } func NewClusterGlobalRateLimiter() *ClusterGlobalRateLimiter { memStorage := memory.New() return &ClusterGlobalRateLimiter{ - memStorage: memStorage, + memStorage: memStorage, globalBucketsMap: make(map[uint64]*leakybucket.Bucket), } } @@ -53,7 +53,7 @@ func (c *ClusterGlobalRateLimiter) getOrCreate(botHash uint64, botLimit uint) *l return b } - globalBucket, _ := c.memStorage.Create(strconv.FormatUint(botHash, 10), botLimit, 1 * time.Second) + globalBucket, _ := c.memStorage.Create(strconv.FormatUint(botHash, 10), botLimit, 1*time.Second) c.globalBucketsMap[botHash] = &globalBucket c.Unlock() return &globalBucket @@ -61,10 +61,8 @@ func (c *ClusterGlobalRateLimiter) getOrCreate(botHash uint64, botLimit uint) *l return b } } - - func (c *ClusterGlobalRateLimiter) FireGlobalRequest(ctx context.Context, addr string, botHash uint64, botLimit uint) error { - globalReq, err := http.NewRequestWithContext(ctx, "GET", "http://" + addr + "/nirn/global", nil) + globalReq, err := http.NewRequestWithContext(ctx, "GET", "http://"+addr+"/nirn/global", nil) if err != nil { return err } @@ -85,4 +83,4 @@ func (c *ClusterGlobalRateLimiter) FireGlobalRequest(ctx context.Context, addr s } return nil -} \ No newline at end of file +} diff --git a/lib/queue.go b/lib/queue.go index 6f8b516..a1e294e 100644 --- a/lib/queue.go +++ b/lib/queue.go @@ -5,6 +5,7 @@ import ( "errors" "io" "net/http" + "slices" "strconv" "strings" "sync" @@ -16,39 +17,89 @@ import ( "github.com/sirupsen/logrus" ) +// A pool of bucketsContextManager +var bucketsContextManagerPool = sync.Pool{ + New: func() interface{} { + return &bucketsContextManager{ + buckets: make([]*Bucket, 0, 1), + } + }, +} + +type bucketsContextManager struct { + buckets []*Bucket +} + +func (b *bucketsContextManager) Acquire(ctx context.Context) error { + // We count till what position we reach instead of using a slice to prevent allocations + var acquiredBucketsCount int + var err error + + for _, bucket := range b.buckets { + err = bucket.Acquire(ctx) + if err != nil { + break + } + + acquiredBucketsCount++ + } + + if err != nil { + // Make sure we release all the buckets we have already acquired before the error + for idx, bucket := range b.buckets { + if idx >= acquiredBucketsCount { + break + } + + bucket.Release() + } + } + + return err +} + +func (b *bucketsContextManager) Release() { + for _, bucket := range b.buckets { + bucket.Release() + } +} + +type ItemProcessFunction func(ctx context.Context, item *QueueItem) (*http.Response, error) + type QueueItem struct { Req *http.Request Res *http.ResponseWriter - ReqBody []byte doneChan chan *http.Response errChan chan error + ReqBody []byte } type QueueChannel struct { - sync.Mutex - ch chan *QueueItem lastUsed time.Time - ratelimit BucketRateLimit + ch chan *QueueItem lockerFun func(item *QueueItem) + buckets []string + sync.Mutex } type RequestQueue struct { - sync.Mutex + globalBucket leakybucket.Bucket globalLockedUntil *int64 // bucket path hash as key - queues map[uint64]*QueueChannel - processor func(ctx context.Context, item *QueueItem) (*http.Response, error) - globalBucket leakybucket.Bucket - // bufferSize Defines the size of the request channel buffer for each bucket - bufferSize int + queues map[uint64]*QueueChannel + buckets map[string]*Bucket + processor ItemProcessFunction user *BotUserResponse - identifier string isTokenInvalid *int64 - botLimit uint - queueType QueueType + identifier string + // bufferSize Defines the size of the request channel buffer for each bucket + bufferSize int + botLimit uint + queueType QueueType + sync.Mutex } -func NewRequestQueue(processor func(ctx context.Context, item *QueueItem) (*http.Response, error), token string, bufferSize int) (*RequestQueue, error) { +func NewRequestQueue(processor ItemProcessFunction, token string, bufferSize int) (*RequestQueue, error) { queueType := NoAuth var user *BotUserResponse var err error @@ -71,6 +122,7 @@ func NewRequestQueue(processor func(ctx context.Context, item *QueueItem) (*http *invalid = 999 return &RequestQueue{ queues: make(map[uint64]*QueueChannel), + buckets: make(map[string]*Bucket), processor: processor, globalBucket: globalBucket, globalLockedUntil: new(int64), @@ -97,6 +149,7 @@ func NewRequestQueue(processor func(ctx context.Context, item *QueueItem) (*http ret := &RequestQueue{ queues: make(map[uint64]*QueueChannel), + buckets: make(map[string]*Bucket), processor: processor, globalBucket: globalBucket, globalLockedUntil: new(int64), @@ -128,10 +181,10 @@ func (q *RequestQueue) destroy() { } } -func (q *RequestQueue) sweep() { +func (q *RequestQueue) sweepQueues() { q.Lock() defer q.Unlock() - logger.Info("Sweep start") + logger.Info("Queues Sweep start") sweptEntries := 0 for key, val := range q.queues { if time.Since(val.lastUsed) > 10*time.Minute { @@ -140,14 +193,36 @@ func (q *RequestQueue) sweep() { sweptEntries++ } } - logger.WithFields(logrus.Fields{"sweptEntries": sweptEntries}).Info("Finished sweep") + logger.WithFields(logrus.Fields{"sweptEntries": sweptEntries}).Info("Finished queues sweep") +} + +func (q *RequestQueue) sweepBuckets() { + q.Lock() + defer q.Unlock() + logger.Debug("Buckets sweep start") + sweptEntries := 0 + for key, val := range q.buckets { + // This is technically a data race, but we are looking for buckets that are insanely + // unused, so we can afford the data race + if val.inTransit == 0 && time.Since(val.serverUpdateAt) > 3*val.period { + delete(q.buckets, key) + sweptEntries++ + } + } + logger.WithFields(logrus.Fields{"sweptEntries": sweptEntries}).Debug("Finished buckets sweep") } func (q *RequestQueue) tickSweep() { t := time.NewTicker(5 * time.Minute) - - for range t.C { - q.sweep() + t2 := time.NewTicker(30 * time.Second) + + for { + select { + case <-t.C: + q.sweepQueues() + case <-t2.C: + q.sweepBuckets() + } } } @@ -169,12 +244,12 @@ func (q *RequestQueue) Queue(req *http.Request, res *http.ResponseWriter, path s "method": req.Method, }).Trace("Inbound request") - ch := q.getQueueChannel(path, req.Method, pathHash) + ch := q.getQueueChannel(path, pathHash) doneChan := make(chan *http.Response) errChan := make(chan error) - safeSend(ch, &QueueItem{req, res, nil, doneChan, errChan}) + safeSend(ch, &QueueItem{Req: req, Res: res, errChan: errChan, doneChan: doneChan}) select { case <-doneChan: @@ -184,16 +259,16 @@ func (q *RequestQueue) Queue(req *http.Request, res *http.ResponseWriter, path s } } -func (q *RequestQueue) getQueueChannel(path, method string, pathHash uint64) *QueueChannel { +func (q *RequestQueue) getQueueChannel(path string, pathHash uint64) *QueueChannel { t := time.Now() q.Lock() defer q.Unlock() ch, ok := q.queues[pathHash] if !ok { ch = &QueueChannel{ - ch: make(chan *QueueItem, q.bufferSize), - lastUsed: t, - ratelimit: NewBucketRatelimit(path, method, q.identifier), + ch: make(chan *QueueItem, q.bufferSize), + buckets: make([]string, 0, 1), + lastUsed: t, } q.queues[pathHash] = ch // It's important that we only have 1 goroutine per channel @@ -297,8 +372,46 @@ func isInteraction(url string) bool { return false } -func (item *QueueItem) doRequest(ctx context.Context, q *RequestQueue, ch *QueueChannel, path string, pathHash uint64) { - defer ch.ratelimit.Release() +func (q *RequestQueue) getBucketsContextManager(ch *QueueChannel) *bucketsContextManager { + q.Lock() + defer q.Unlock() + ch.Lock() + defer ch.Unlock() + + if len(ch.buckets) == 0 { + return nil + } + + contextManager := bucketsContextManagerPool.Get().(*bucketsContextManager) + contextManager.buckets = contextManager.buckets[:0] + + for idx := 0; idx < len(ch.buckets); { + bucket, ok := q.buckets[ch.buckets[idx]] + if ok { + contextManager.buckets = append(contextManager.buckets, bucket) + idx++ + continue + } + + // The bucket no longer exists, so remove it from the channel slice + ch.buckets = append(ch.buckets[:idx], ch.buckets[idx+1:]...) + } + + if len(contextManager.buckets) == 0 { + bucketsContextManagerPool.Put(contextManager) + return nil + } + + return contextManager +} + +func (q *RequestQueue) doRequest(ctx context.Context, item *QueueItem, ch *QueueChannel, buckets *bucketsContextManager, path, pathHash string) { + if buckets != nil { + defer func() { + buckets.Release() + bucketsContextManagerPool.Put(buckets) + }() + } resp, err := q.processor(ctx, item) if err != nil { @@ -306,7 +419,7 @@ func (item *QueueItem) doRequest(ctx context.Context, q *RequestQueue, ch *Queue return } - bucket, remaining, limit, resetAfter, resetAt, scope, err := parseHeaders(&resp.Header) + bucketHash, remaining, limit, resetAfter, resetAt, scope, err := parseHeaders(&resp.Header) if err != nil { item.errChan <- err return @@ -331,8 +444,59 @@ func (item *QueueItem) doRequest(ctx context.Context, q *RequestQueue, ch *Queue ratelimitHit := resp.StatusCode == 429 - if bucket != "" || ratelimitHit { - ch.ratelimit.Update(bucket, remaining, limit, resetAt, resetAfter, ratelimitHit) + if bucketHash != "" || ratelimitHit { + if bucketHash == "" { + // We might have hit a Cloudflare 429, so we create a special bucket for that + bucketHash = "route" + } + + // Bucket hashes are per path hash + bucketHash += ":" + pathHash + + q.Lock() + bucket, ok := q.buckets[bucketHash] + if !ok { + logger.WithFields(logrus.Fields{ + "bucket": bucketHash, + "remaining": remaining, + "limit": limit, + "resetAt": resetAt, + "resetAfter": resetAfter, + "identifier": q.identifier, + "route": item.Req.URL.String(), + "method": item.Req.Method, + }).Debug("creating new bucket") + + q.buckets[bucketHash] = NewBucket(bucketHash, remaining, limit, resetAt, resetAfter) + } else { + logger.WithFields(logrus.Fields{ + "bucket": bucketHash, + "remaining": remaining, + "limit": limit, + "resetAt": resetAt, + "resetAfter": resetAfter, + "identifier": q.identifier, + "route": item.Req.URL.String(), + "method": item.Req.Method, + }).Debug("updating existing bucket") + + bucket.Update(remaining, limit, resetAt, resetAfter, ratelimitHit) + } + q.Unlock() + + ch.Lock() + if !slices.Contains(ch.buckets, bucketHash) { + logger.WithFields(logrus.Fields{ + "bucket": bucketHash, + "identifier": q.identifier, + "route": item.Req.URL.String(), + "method": item.Req.Method, + }).Debug("linking new bucket to route") + + ch.buckets = append(ch.buckets, bucketHash) + } + ch.Unlock() + } if ratelimitHit && scope != "shared" { @@ -340,12 +504,12 @@ func (item *QueueItem) doRequest(ctx context.Context, q *RequestQueue, ch *Queue "remaining": remaining, "resetAfter": resetAfter, "bucket": path, + "identifier": q.identifier, "route": item.Req.URL.String(), "method": item.Req.Method, - "scope": scope, "pathHash": pathHash, // TODO: Remove this when 429s are not a problem anymore - "discordBucket": bucket, + "discordBucket": bucketHash, "ratelimitScope": scope, }).Warn("Unexpected 429") return @@ -381,10 +545,12 @@ func (item *QueueItem) doRequest(ctx context.Context, q *RequestQueue, ch *Queue } } -func (q *RequestQueue) subscribe(ch *QueueChannel, path string, pathHash uint64) { +func (q *RequestQueue) subscribe(ch *QueueChannel, path string, pathHashInt uint64) { // This function has 1 goroutine for each bucket path // Locking here is not needed + pathHash := strconv.FormatUint(pathHashInt, 10) + for item := range ch.ch { ctx := context.WithValue(item.Req.Context(), "identifier", q.identifier) @@ -420,9 +586,14 @@ func (q *RequestQueue) subscribe(ch *QueueChannel, path string, pathHash uint64) } _ = item.Req.Body.Close() - if err = ch.ratelimit.Acquire(ctx); err != nil { - item.errChan <- err - continue + buckets := q.getBucketsContextManager(ch) + + if buckets != nil { + if err = buckets.Acquire(ctx); err != nil { + bucketsContextManagerPool.Put(buckets) + item.errChan <- err + continue + } } // We don't have the initial headers, so we do the requests sequentially, which should @@ -431,10 +602,10 @@ func (q *RequestQueue) subscribe(ch *QueueChannel, path string, pathHash uint64) // which should be fine // // TODO: Consider if its worth hard coding which routes will never have a bucket - if ch.ratelimit.unknown || !allowConcurrentRequests { - item.doRequest(ctx, q, ch, path, pathHash) + if buckets == nil || !allowConcurrentRequests { + q.doRequest(ctx, item, ch, buckets, path, pathHash) } else { - go item.doRequest(ctx, q, ch, path, pathHash) + go q.doRequest(ctx, item, ch, buckets, path, pathHash) } } } diff --git a/lib/queue_manager.go b/lib/queue_manager.go index 21c511c..be54372 100644 --- a/lib/queue_manager.go +++ b/lib/queue_manager.go @@ -3,15 +3,16 @@ package lib import ( "context" "errors" - lru "github.com/hashicorp/golang-lru" - "github.com/hashicorp/memberlist" - "github.com/sirupsen/logrus" "net/http" "sort" "strconv" "strings" "sync" "time" + + lru "github.com/hashicorp/golang-lru" + "github.com/hashicorp/memberlist" + "github.com/sirupsen/logrus" ) type QueueType int64 @@ -30,18 +31,18 @@ var pathsToRouteLocally = map[uint64]struct{}{ } type QueueManager struct { - sync.RWMutex queues map[string]*RequestQueue bearerQueues *lru.Cache - bearerMu sync.RWMutex - bufferSize int cluster *memberlist.Memberlist clusterGlobalRateLimiter *ClusterGlobalRateLimiter - orderedClusterMembers []string nameToAddressMap map[string]string localNodeName string localNodeIP string localNodeProxyListenAddr string + orderedClusterMembers []string + bufferSize int + sync.RWMutex + bearerMu sync.RWMutex } func onEvictLruItem(key interface{}, value interface{}) { From 59a9452ce5aedf6cbf61d12f501bd9ecb117b8fe Mon Sep 17 00:00:00 2001 From: davfsa Date: Sat, 10 Jan 2026 00:41:00 +0100 Subject: [PATCH 076/105] test: allow sliding window even if out of sync --- lib/bucket.go | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/lib/bucket.go b/lib/bucket.go index 4f1f90c..b203289 100644 --- a/lib/bucket.go +++ b/lib/bucket.go @@ -87,12 +87,7 @@ func NewBucket(bucket string, remaining, limit int64, resetAt, resetAfter float6 // Warning: this MUST be called from a locked state func (b *Bucket) isRatelimited(now time.Time) bool { - // If we are out of sync, we shouldn't slide the window along, as we will be off due to - // network latency. - // - // The second part of this 'if' is for self-healing purposes, to account for the weird case where - // an error occurs and the bucket is not updated properly, becoming permanently out of sync - if now.After(b.increaseAt) && (!b.outOfSync || now.Sub(b.increaseAt) > b.period) { + if now.After(b.increaseAt) { if b.fixedWindow || b.ratelimitAvoidance { // Fixed windows or ratelimit avoidance just reset the remaining back to the limit b.remaining = b.limit From 32a510ce3b68935e637143020fa61ede7b302606 Mon Sep 17 00:00:00 2001 From: davfsa Date: Sat, 10 Jan 2026 00:52:21 +0100 Subject: [PATCH 077/105] fix: use proper parameter for buckets sweep --- lib/bucket.go | 46 ++++++++++++++++++++++------------------------ lib/queue.go | 2 +- 2 files changed, 23 insertions(+), 25 deletions(-) diff --git a/lib/bucket.go b/lib/bucket.go index b203289..5c24870 100644 --- a/lib/bucket.go +++ b/lib/bucket.go @@ -36,15 +36,15 @@ func calculateSlidingWindow(remaining, limit int64, resetAt, resetAfter float64) // Bucket is a Discord bucket ratelimiter type Bucket struct { increaseAt time.Time - serverUpdateAt time.Time transitWaitChan chan interface{} - bucket string - remaining int64 - limit int64 - period time.Duration - resetAt float64 - inTransit int64 + bucket string + remaining int64 + limit int64 + period time.Duration + resetAt float64 + resetAfter float64 + inTransit int64 stateLock sync.Mutex inTransitLock sync.Mutex @@ -75,9 +75,10 @@ func NewBucket(bucket string, remaining, limit int64, resetAt, resetAfter float6 return &Bucket{ bucket: bucket, - remaining: remaining, + remaining: remaining + 100, limit: limit, resetAt: resetAt, + resetAfter: resetAfter, period: period, increaseAt: increaseAt, fixedWindow: fixedWindow, @@ -202,19 +203,25 @@ func (b *Bucket) Release() { } func (b *Bucket) Update(remaining, limit int64, resetAt, resetAfter float64, ratelimitHit bool) { - resetAtTime := time.Unix(0, int64(resetAt*1_000_000_000)) - resetAfterDuration := time.Duration(resetAfter*1_000) * time.Millisecond - serverUpdateAt := resetAtTime.Add(-resetAfterDuration) - b.stateLock.Lock() defer b.stateLock.Unlock() - if serverUpdateAt.Before(b.serverUpdateAt) { + if resetAt-resetAfter < b.resetAt-b.resetAfter { // Old ratelimit information, ignore return } - b.serverUpdateAt = serverUpdateAt + if ratelimitHit { + // During ratelimit avoidance, we will treat the bucket as fixed + // bucket and wait for it to fill up completely + b.ratelimitAvoidance = true + _, increaseAt := calculateFixedWindow(resetAt, resetAfter) + b.increaseAt = increaseAt + b.remaining = 0 + b.resetAt = resetAt + b.resetAfter = resetAfter + return + } if b.firstSeen && !b.outOfSync && remaining > 0 && remaining != limit-1 { resetAtEq := isClose(b.resetAt, resetAt, 0.05) @@ -243,16 +250,7 @@ func (b *Bucket) Update(remaining, limit int64, resetAt, resetAfter float64, rat } b.resetAt = resetAt - - if ratelimitHit { - // During ratelimit avoidance, we will treat the bucket as fixed - // bucket and wait for it to fill up completely - b.ratelimitAvoidance = true - _, increaseAt := calculateFixedWindow(resetAt, resetAfter) - b.increaseAt = increaseAt - b.remaining = 0 - return - } + b.resetAfter = resetAfter if !b.outOfSync { return diff --git a/lib/queue.go b/lib/queue.go index a1e294e..9b3e24f 100644 --- a/lib/queue.go +++ b/lib/queue.go @@ -204,7 +204,7 @@ func (q *RequestQueue) sweepBuckets() { for key, val := range q.buckets { // This is technically a data race, but we are looking for buckets that are insanely // unused, so we can afford the data race - if val.inTransit == 0 && time.Since(val.serverUpdateAt) > 3*val.period { + if val.inTransit == 0 && time.Since(val.increaseAt) > 3*val.period { delete(q.buckets, key) sweptEntries++ } From 99ece9e4a65cd0b9e84c36f330d0165bea14d291 Mon Sep 17 00:00:00 2001 From: davfsa Date: Sat, 10 Jan 2026 01:21:53 +0100 Subject: [PATCH 078/105] fix: remove test code --- lib/bucket.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/bucket.go b/lib/bucket.go index 5c24870..a550da4 100644 --- a/lib/bucket.go +++ b/lib/bucket.go @@ -75,7 +75,7 @@ func NewBucket(bucket string, remaining, limit int64, resetAt, resetAfter float6 return &Bucket{ bucket: bucket, - remaining: remaining + 100, + remaining: remaining, limit: limit, resetAt: resetAt, resetAfter: resetAfter, From 99a31096ad0e1d2fa90158f94058402618cef839 Mon Sep 17 00:00:00 2001 From: davfsa Date: Sat, 10 Jan 2026 02:06:14 +0100 Subject: [PATCH 079/105] chore: account for network delay --- lib/bucket.go | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/lib/bucket.go b/lib/bucket.go index a550da4..86c9999 100644 --- a/lib/bucket.go +++ b/lib/bucket.go @@ -21,14 +21,10 @@ func calculateFixedWindow(resetAt, resetAfter float64) (time.Duration, time.Time return period, increaseAt } -func calculateSlidingWindow(remaining, limit int64, resetAt, resetAfter float64) (time.Duration, time.Time) { +func calculateSlidingWindow(remaining, limit int64, resetAfter float64) (time.Duration, time.Time) { // slidePeriod = resetAfter / (limit - remaining) slidePeriod := time.Duration(math.Ceil((resetAfter/float64(limit-remaining))*1_000)) * time.Millisecond - - // increaseAt = (resetAt - resetAfter) + slidePeriod - resetAtTime := time.Unix(0, int64(resetAt*1_000_000_000)) - resetAfterDuration := time.Duration(resetAfter*1_000) * time.Millisecond - increaseAt := resetAtTime.Add(-resetAfterDuration).Add(slidePeriod) + increaseAt := time.Now().Add(slidePeriod) return slidePeriod, increaseAt } @@ -64,7 +60,7 @@ func NewBucket(bucket string, remaining, limit int64, resetAt, resetAfter float6 if limit != 1 && remaining == limit-1 { // We have the perfect condition for a sliding window, so assume that for now. // Turning it into a fixed bucket later is preferable, as we might never get this chance again - period, increaseAt = calculateSlidingWindow(remaining, limit, resetAt, resetAfter) + period, increaseAt = calculateSlidingWindow(remaining, limit, resetAfter) fixedWindow = false } else { // We can assume its a fixed bucket for now, and hope that in the future we will get @@ -88,7 +84,7 @@ func NewBucket(bucket string, remaining, limit int64, resetAt, resetAfter float6 // Warning: this MUST be called from a locked state func (b *Bucket) isRatelimited(now time.Time) bool { - if now.After(b.increaseAt) { + if now.After(b.increaseAt) || now.Equal(b.increaseAt) { if b.fixedWindow || b.ratelimitAvoidance { // Fixed windows or ratelimit avoidance just reset the remaining back to the limit b.remaining = b.limit @@ -261,7 +257,7 @@ func (b *Bucket) Update(remaining, limit int64, resetAt, resetAfter float64, rat b.period = period b.increaseAt = increaseAt } else { - period, increaseAt := calculateSlidingWindow(remaining, limit, resetAt, resetAfter) + period, increaseAt := calculateSlidingWindow(remaining, limit, resetAfter) b.period = period b.increaseAt = increaseAt } From 28fe7613c530dfa7f870bb3e64faa8aa0e2f1176 Mon Sep 17 00:00:00 2001 From: davfsa Date: Sat, 10 Jan 2026 10:35:12 +0100 Subject: [PATCH 080/105] chore: cleanup --- lib/bucket.go | 58 +++++++++++++++++++-------------------------------- 1 file changed, 22 insertions(+), 36 deletions(-) diff --git a/lib/bucket.go b/lib/bucket.go index 86c9999..489ae1e 100644 --- a/lib/bucket.go +++ b/lib/bucket.go @@ -84,12 +84,12 @@ func NewBucket(bucket string, remaining, limit int64, resetAt, resetAfter float6 // Warning: this MUST be called from a locked state func (b *Bucket) isRatelimited(now time.Time) bool { - if now.After(b.increaseAt) || now.Equal(b.increaseAt) { + if (now.After(b.increaseAt) || now.Equal(b.increaseAt)) && (!b.outOfSync || now.Sub(b.increaseAt) > b.period) { if b.fixedWindow || b.ratelimitAvoidance { // Fixed windows or ratelimit avoidance just reset the remaining back to the limit b.remaining = b.limit - b.outOfSync = true b.increaseAt = now.Add(b.period) + b.outOfSync = true b.ratelimitAvoidance = false } else { @@ -153,29 +153,23 @@ func (b *Bucket) Acquire(ctx context.Context) error { sleepDuration := b.increaseAt.Sub(now) b.stateLock.Unlock() - select { - case <-ctx.Done(): - b.Release() - return ctx.Err() - default: - } - if sleepDuration > 0 { logger.WithFields(logrus.Fields{ "bucket": b.bucket, "sleepDuration": sleepDuration, }).Debug("backing off to avoid hitting ratelimits") + } else { + sleepDuration = time.Duration(0) + } - select { - case <-ctx.Done(): - b.Release() - return ctx.Err() - case <-time.After(sleepDuration): - } + select { + case <-ctx.Done(): + b.Release() + return ctx.Err() + case <-time.After(sleepDuration): } } - // FIXME: Consider not decrementing remaining until release and make sure the request was made b.remaining-- b.stateLock.Unlock() return nil @@ -202,17 +196,11 @@ func (b *Bucket) Update(remaining, limit int64, resetAt, resetAfter float64, rat b.stateLock.Lock() defer b.stateLock.Unlock() - if resetAt-resetAfter < b.resetAt-b.resetAfter { - // Old ratelimit information, ignore - return - } - if ratelimitHit { // During ratelimit avoidance, we will treat the bucket as fixed // bucket and wait for it to fill up completely b.ratelimitAvoidance = true - _, increaseAt := calculateFixedWindow(resetAt, resetAfter) - b.increaseAt = increaseAt + _, b.increaseAt = calculateFixedWindow(resetAt, resetAfter) b.remaining = 0 b.resetAt = resetAt b.resetAfter = resetAfter @@ -248,19 +236,17 @@ func (b *Bucket) Update(remaining, limit int64, resetAt, resetAfter float64, rat b.resetAt = resetAt b.resetAfter = resetAfter - if !b.outOfSync { - return - } + if b.outOfSync || (limit != 1 && remaining == limit-1) { + if b.fixedWindow { + period, increaseAt := calculateFixedWindow(resetAt, resetAfter) + b.period = period + b.increaseAt = increaseAt + } else { + period, increaseAt := calculateSlidingWindow(remaining, limit, resetAfter) + b.period = period + b.increaseAt = increaseAt + } - if b.fixedWindow { - period, increaseAt := calculateFixedWindow(resetAt, resetAfter) - b.period = period - b.increaseAt = increaseAt - } else { - period, increaseAt := calculateSlidingWindow(remaining, limit, resetAfter) - b.period = period - b.increaseAt = increaseAt + b.outOfSync = false } - - b.outOfSync = false } From f4eb6428226acc182995d4e90f57e621ae4b743d Mon Sep 17 00:00:00 2001 From: davfsa Date: Sat, 10 Jan 2026 11:15:13 +0100 Subject: [PATCH 081/105] chore: remove unused field --- lib/bucket.go | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/lib/bucket.go b/lib/bucket.go index 489ae1e..aed5685 100644 --- a/lib/bucket.go +++ b/lib/bucket.go @@ -34,13 +34,12 @@ type Bucket struct { increaseAt time.Time transitWaitChan chan interface{} - bucket string - remaining int64 - limit int64 - period time.Duration - resetAt float64 - resetAfter float64 - inTransit int64 + bucket string + remaining int64 + limit int64 + period time.Duration + resetAt float64 + inTransit int64 stateLock sync.Mutex inTransitLock sync.Mutex @@ -74,7 +73,6 @@ func NewBucket(bucket string, remaining, limit int64, resetAt, resetAfter float6 remaining: remaining, limit: limit, resetAt: resetAt, - resetAfter: resetAfter, period: period, increaseAt: increaseAt, fixedWindow: fixedWindow, @@ -203,7 +201,6 @@ func (b *Bucket) Update(remaining, limit int64, resetAt, resetAfter float64, rat _, b.increaseAt = calculateFixedWindow(resetAt, resetAfter) b.remaining = 0 b.resetAt = resetAt - b.resetAfter = resetAfter return } @@ -234,7 +231,6 @@ func (b *Bucket) Update(remaining, limit int64, resetAt, resetAfter float64, rat } b.resetAt = resetAt - b.resetAfter = resetAfter if b.outOfSync || (limit != 1 && remaining == limit-1) { if b.fixedWindow { From 0ebe4e1050ad3e7ff84affbc196ac462dc1f310c Mon Sep 17 00:00:00 2001 From: davfsa Date: Sat, 10 Jan 2026 18:33:49 +0100 Subject: [PATCH 082/105] fix: remove incorrect assumption of major channels bucket --- lib/bucketpath.go | 16 ---------------- lib/bucketpath_test.go | 2 +- 2 files changed, 1 insertion(+), 17 deletions(-) diff --git a/lib/bucketpath.go b/lib/bucketpath.go index 04e06f8..85fa943 100644 --- a/lib/bucketpath.go +++ b/lib/bucketpath.go @@ -90,31 +90,15 @@ func GetOptimisticBucketPath(url string, method string) string { currMajor := MajorUnknown // ! stands for any replaceable id switch parts[0] { - case MajorChannels: - if numParts == 2 { - // Return the same bucket for all reqs to /channels/id - // In this case, the discord bucket is the same regardless of the id - bucket.WriteString(MajorChannels) - bucket.WriteString("/!") - return bucket.String() - } - bucket.WriteString(MajorChannels) - bucket.WriteByte('/') - bucket.WriteString(parts[1]) - currMajor = MajorChannels case MajorInvites: bucket.WriteString(MajorInvites) bucket.WriteString("/!") currMajor = MajorInvites - case MajorGuilds: - fallthrough case MajorInteractions: if numParts == 4 && parts[3] == "callback" { return "/" + MajorInteractions + "/" + parts[1] + "/!/callback" } fallthrough - case MajorWebhooks: - fallthrough default: bucket.WriteString(parts[0]) bucket.WriteByte('/') diff --git a/lib/bucketpath_test.go b/lib/bucketpath_test.go index 92a15c7..19931d6 100644 --- a/lib/bucketpath_test.go +++ b/lib/bucketpath_test.go @@ -14,7 +14,7 @@ func TestPaths(t *testing.T) { // Guild Major {"/api/v9/guilds/103039963636301824", "GET", "/guilds/103039963636301824"}, // Channel major - {"/api/v8/channels/203039963636301824", "GET", "/channels/!"}, + {"/api/v8/channels/203039963636301824", "GET", "/channels/203039963636301824"}, {"/api/v7/channels/203039963636301824/pins", "GET", "/channels/203039963636301824/pins"}, {"/api/v6/channels/872712139712913438/messages/872712150509047809/reactions/%F0%9F%98%8B", "GET", "/channels/872712139712913438/messages/!/reactions/!/!"}, {"/api/v10/channels/872712139712913438/messages/872712150509047809/reactions/PandaOhShit:863985751205085195", "GET", "/channels/872712139712913438/messages/!/reactions/!/!"}, From e45bbdb32dc8befdd1d9c24eeb603727521c17c2 Mon Sep 17 00:00:00 2001 From: davfsa Date: Sat, 10 Jan 2026 18:33:57 +0100 Subject: [PATCH 083/105] feat: improve logging --- lib/queue.go | 3 +++ lib/queue_manager.go | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/queue.go b/lib/queue.go index 9b3e24f..cef2d2c 100644 --- a/lib/queue.go +++ b/lib/queue.go @@ -463,6 +463,7 @@ func (q *RequestQueue) doRequest(ctx context.Context, item *QueueItem, ch *Queue "resetAt": resetAt, "resetAfter": resetAfter, "identifier": q.identifier, + "path": path, "route": item.Req.URL.String(), "method": item.Req.Method, }).Debug("creating new bucket") @@ -476,6 +477,7 @@ func (q *RequestQueue) doRequest(ctx context.Context, item *QueueItem, ch *Queue "resetAt": resetAt, "resetAfter": resetAfter, "identifier": q.identifier, + "path": path, "route": item.Req.URL.String(), "method": item.Req.Method, }).Debug("updating existing bucket") @@ -491,6 +493,7 @@ func (q *RequestQueue) doRequest(ctx context.Context, item *QueueItem, ch *Queue "identifier": q.identifier, "route": item.Req.URL.String(), "method": item.Req.Method, + "path": path, }).Debug("linking new bucket to route") ch.buckets = append(ch.buckets, bucketHash) diff --git a/lib/queue_manager.go b/lib/queue_manager.go index be54372..e4789fc 100644 --- a/lib/queue_manager.go +++ b/lib/queue_manager.go @@ -337,7 +337,8 @@ func (m *QueueManager) fulfillRequest(resp *http.ResponseWriter, req *http.Reque if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { log.WithFields(logrus.Fields{ "waitedFor": time.Since(reqStart), - "path": req.URL.Path, + "method": req.Method, + "route": req.URL.Path, }).Warn(err) } else { log.Error(err) From 6cd4471c005b37052faa7e2484c85da65f1e4883 Mon Sep 17 00:00:00 2001 From: davfsa Date: Sat, 10 Jan 2026 23:09:26 +0100 Subject: [PATCH 084/105] feat: enable concurrent requests by default --- CONFIG.md | 2 +- README.md | 2 +- main.go | 12 +++++++----- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/CONFIG.md b/CONFIG.md index 61b79c4..437772f 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -80,7 +80,7 @@ Allows nirn to perform concurrent requests to Discord endpoints, instead of one If you do not care about throughput or do not make a lot of requests to the same endpoint that might take Discord a while to answer, then it would be fine to keep this off. -Default: false +Default: true ## Unstable env vars Collection of env vars that may be removed at any time, mainly used for Discord introducing new behaviour on their edge api versions diff --git a/README.md b/README.md index b5c7261..dbc8a60 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ Configuration options are | DISABLE_HTTP_2 | bool | true | | BOT_RATELIMIT_OVERRIDES | string list (comma separated) | "" | | DISABLE_GLOBAL_RATELIMIT_DETECTION | boolean | false | -| ALLOW_CONCURRENT_REQUESTS | boolean | false | +| ALLOW_CONCURRENT_REQUESTS | boolean | true | Information on each config var can be found [here](CONFIG.md) diff --git a/main.go b/main.go index bcf237f..b6efd47 100644 --- a/main.go +++ b/main.go @@ -3,10 +3,6 @@ package main import ( "context" "errors" - "github.com/germanoeich/nirn-proxy/lib" - "github.com/hashicorp/memberlist" - _ "github.com/joho/godotenv/autoload" - "github.com/sirupsen/logrus" "net" "net/http" "os" @@ -14,6 +10,12 @@ import ( "strings" "syscall" "time" + + "github.com/hashicorp/memberlist" + _ "github.com/joho/godotenv/autoload" + "github.com/sirupsen/logrus" + + "github.com/germanoeich/nirn-proxy/lib" ) var logger = logrus.New() @@ -76,7 +78,7 @@ func main() { globalOverrides := lib.EnvGet("BOT_RATELIMIT_OVERRIDES", "") disableGlobalRatelimitDetection := lib.EnvGetBool("DISABLE_GLOBAL_RATELIMIT_DETECTION", false) - allowConcurrentRequests := lib.EnvGetBool("ALLOW_CONCURRENT_REQUESTS", false) + allowConcurrentRequests := lib.EnvGetBool("ALLOW_CONCURRENT_REQUESTS", true) lib.ConfigureDiscordHTTPClient(outboundIp, time.Duration(timeout)*time.Millisecond, globalOverrides, disableHttp2, disableGlobalRatelimitDetection, allowConcurrentRequests) From 5fa606bcd11be8e1030eaa5a45761f1c7e061176 Mon Sep 17 00:00:00 2001 From: davfsa Date: Sun, 11 Jan 2026 12:27:46 +0100 Subject: [PATCH 085/105] feat: improve handling of buckets with top level resources and multiple sub-buckets --- lib/bucket.go | 63 +++++++++++++++------- lib/bucketpath.go | 120 ++++++++++++++++++++++++----------------- lib/bucketpath_test.go | 11 +++- lib/queue.go | 35 ++++++------ lib/queue_manager.go | 12 ++--- 5 files changed, 146 insertions(+), 95 deletions(-) diff --git a/lib/bucket.go b/lib/bucket.go index aed5685..5a539cd 100644 --- a/lib/bucket.go +++ b/lib/bucket.go @@ -34,21 +34,23 @@ type Bucket struct { increaseAt time.Time transitWaitChan chan interface{} - bucket string - remaining int64 - limit int64 - period time.Duration - resetAt float64 + // under stateLock + bucket string + remaining int64 + limit int64 + period time.Duration + resetAt time.Time + serverUpdatedAt time.Time + // under inTransitLock inTransit int64 stateLock sync.Mutex inTransitLock sync.Mutex acquireLock sync.Mutex - outOfSync bool - fixedWindow bool - firstSeen bool - ratelimitAvoidance bool + outOfSync bool + fixedWindow bool + firstSeen bool } func NewBucket(bucket string, remaining, limit int64, resetAt, resetAfter float64) *Bucket { @@ -72,7 +74,7 @@ func NewBucket(bucket string, remaining, limit int64, resetAt, resetAfter float6 bucket: bucket, remaining: remaining, limit: limit, - resetAt: resetAt, + resetAt: time.Unix(0, int64(resetAt*1_000_000_000)), period: period, increaseAt: increaseAt, fixedWindow: fixedWindow, @@ -82,16 +84,24 @@ func NewBucket(bucket string, remaining, limit int64, resetAt, resetAfter float6 // Warning: this MUST be called from a locked state func (b *Bucket) isRatelimited(now time.Time) bool { - if (now.After(b.increaseAt) || now.Equal(b.increaseAt)) && (!b.outOfSync || now.Sub(b.increaseAt) > b.period) { - if b.fixedWindow || b.ratelimitAvoidance { - // Fixed windows or ratelimit avoidance just reset the remaining back to the limit + canIncrease := now.After(b.increaseAt) + canReset := now.After(b.resetAt) + + if (canIncrease && !b.outOfSync) || canReset { + if b.fixedWindow { + // Fixed windows just reset the remaining back to the limit b.remaining = b.limit b.increaseAt = now.Add(b.period) + b.resetAt = b.increaseAt + b.outOfSync = true + } else if canReset { + // Sliding bucket being fully reset + b.remaining = b.limit + b.increaseAt = now.Add(b.period) + b.resetAt = now.Add(b.period * time.Duration(b.limit)) b.outOfSync = true - b.ratelimitAvoidance = false - } else { - // We can slide the window along + // Slide window along gain := int64(math.Floor((now.Sub(b.increaseAt).Seconds())/b.period.Seconds())) + 1 nowRemaining := b.remaining + gain @@ -101,9 +111,11 @@ func (b *Bucket) isRatelimited(now time.Time) bool { // When a ratelimit resets, we will fall out of sync from the remote, so // we want to prevent future sliding b.increaseAt = now.Add(b.period) + b.resetAt = now.Add(b.period * time.Duration(b.limit)) b.outOfSync = true } else { b.increaseAt = b.increaseAt.Add(b.period * time.Duration(gain)) + b.resetAt = b.resetAt.Add(b.period * time.Duration(gain)) } } } @@ -115,6 +127,7 @@ func (b *Bucket) isRatelimited(now time.Time) bool { func (b *Bucket) Acquire(ctx context.Context) error { b.acquireLock.Lock() defer b.acquireLock.Unlock() + b.inTransitLock.Lock() if b.inTransit >= b.limit { // Buffer of 1 here to prevent deadlocks in a worst case scenario @@ -194,18 +207,26 @@ func (b *Bucket) Update(remaining, limit int64, resetAt, resetAfter float64, rat b.stateLock.Lock() defer b.stateLock.Unlock() + resetAtTime := time.Unix(0, int64(resetAt*1_000_000_000)) + resetAfterDuration := time.Duration(resetAfter*1_000) * time.Millisecond + serverUpdatedAt := resetAtTime.Add(-resetAfterDuration) + + if b.serverUpdatedAt.Before(serverUpdatedAt) { + b.serverUpdatedAt = serverUpdatedAt + } + if ratelimitHit { // During ratelimit avoidance, we will treat the bucket as fixed // bucket and wait for it to fill up completely - b.ratelimitAvoidance = true _, b.increaseAt = calculateFixedWindow(resetAt, resetAfter) + b.resetAt = resetAtTime b.remaining = 0 - b.resetAt = resetAt + b.outOfSync = false return } if b.firstSeen && !b.outOfSync && remaining > 0 && remaining != limit-1 { - resetAtEq := isClose(b.resetAt, resetAt, 0.05) + resetAtEq := isClose(float64(b.resetAt.UnixMilli()/1_000_000), resetAt, 0.05) b.firstSeen = false if !b.fixedWindow && resetAtEq { @@ -230,7 +251,9 @@ func (b *Bucket) Update(remaining, limit int64, resetAt, resetAfter float64, rat } } - b.resetAt = resetAt + if b.resetAt.Before(resetAtTime) { + b.resetAt = resetAtTime + } if b.outOfSync || (limit != 1 && remaining == limit-1) { if b.fixedWindow { diff --git a/lib/bucketpath.go b/lib/bucketpath.go index 85fa943..ff9baba 100644 --- a/lib/bucketpath.go +++ b/lib/bucketpath.go @@ -8,7 +8,6 @@ import ( ) const ( - MajorUnknown = "unk" MajorChannels = "channels" MajorGuilds = "guilds" MajorWebhooks = "webhooks" @@ -39,7 +38,7 @@ func IsNumericInput(str string) bool { } func GetMetricsPath(route string) string { - route = GetOptimisticBucketPath(route, "") + route, _ = GetOptimisticBucketPath(route, "") var path = "" parts := strings.Split(route, "/") @@ -66,7 +65,37 @@ func GetMetricsPath(route string) string { return path } -func GetOptimisticBucketPath(url string, method string) string { +func majorParamHash(major string, parts ...string) uint64 { + hashStr := major + + for _, part := range parts { + hashStr += ":" + part + } + + return HashCRC64(hashStr) +} + +func tokenInfo(token string) string { + // aW50ZXJhY3Rpb246 is base64 for "interaction:" + if !strings.HasPrefix(token, "aW50ZXJhY3Rpb246") { + return "/!" + } + + // fix padding + if i := len(token) % 4; i != 0 { + token += strings.Repeat("=", 4-i) + } + + decodedPart, err := base64.StdEncoding.DecodeString(token) + if err != nil { + return "/unknown" + } + + interactionId := strings.Split(string(decodedPart), ":")[1] + return "/" + interactionId +} + +func GetOptimisticBucketPath(url string, method string) (string, uint64) { bucket := strings.Builder{} bucket.WriteByte('/') cleanUrl := strings.SplitN(url, "?", 1)[0] @@ -84,19 +113,41 @@ func GetOptimisticBucketPath(url string, method string) string { numParts := len(parts) if numParts <= 1 { - return cleanUrl + return cleanUrl, HashCRC64(cleanUrl) } - currMajor := MajorUnknown + currMajor := parts[0] + var majorParamsHash uint64 // ! stands for any replaceable id switch parts[0] { case MajorInvites: bucket.WriteString(MajorInvites) bucket.WriteString("/!") + currMajor = MajorInvites + majorParamsHash = majorParamHash(MajorInvites) + parts = parts[2:] + case MajorWebhooks: + bucket.WriteString(MajorWebhooks) + bucket.WriteByte('/') + bucket.WriteString(parts[1]) + + currMajor = MajorWebhooks + // Webhook tokens are optional, and they fall under different top level resources + if numParts > 2 && len(parts[2]) >= 64 { + // webhook_id + token + bucket.WriteString(tokenInfo(parts[2])) + majorParamsHash = majorParamHash(MajorWebhooks, parts[1], parts[2]) + parts = parts[3:] + } else { + // just webhook_id + majorParamsHash = majorParamHash(MajorWebhooks, parts[1]) + parts = parts[2:] + } case MajorInteractions: if numParts == 4 && parts[3] == "callback" { - return "/" + MajorInteractions + "/" + parts[1] + "/!/callback" + // Hash 0 is a special case for "no ratelimits" + return "/" + MajorInteractions + "/" + parts[1] + "/!/callback", 0 } fallthrough default: @@ -104,29 +155,30 @@ func GetOptimisticBucketPath(url string, method string) string { bucket.WriteByte('/') bucket.WriteString(parts[1]) currMajor = parts[0] + majorParamsHash = majorParamHash(currMajor, parts[1]) + parts = parts[2:] } if numParts == 2 { - return bucket.String() + return bucket.String(), majorParamsHash } - // At this point, the major + id part is already accounted for + // At this point, the major + id part is already accounted for (and trimmed out of 'parts') // In this loop, we only need to strip all remaining snowflakes, emoji names and webhook tokens(optional) - parts = parts[2:] - for idx, part := range parts { if IsSnowflake(part) { - //Custom rule for message DELETES older than 14d - if currMajor == MajorChannels && idx == len(parts)-1 && parts[idx-1] == "messages" && method == "DELETE" { + //Custom rule for message DELETES older than 14d and message PATCHES older than 1h + if currMajor == MajorChannels && idx == len(parts)-1 && parts[idx-1] == "messages" { createdAt, _ := GetSnowflakeCreatedAt(part) - diff := time.Now().Sub(createdAt) + diff := time.Since(createdAt) - if diff >= 14*24*time.Hour { + if method == "DELETE" && diff >= 14*24*time.Hour { bucket.WriteString("/!14dmsg") - } else { - bucket.WriteString("/!") + continue + } else if method == "PATCH" && diff >= 1*time.Hour { + bucket.WriteString("/!1hmsg") + continue } - continue } bucket.WriteString("/!") @@ -146,41 +198,9 @@ func GetOptimisticBucketPath(url string, method string) string { break } - // Strip webhook tokens, or extract interaction ID - if len(part) >= 64 { - // aW50ZXJhY3Rpb246 is base64 for "interaction:" - if !strings.HasPrefix(part, "aW50ZXJhY3Rpb246") { - bucket.WriteString("/!") - continue - } - - var interactionId string - - // fix padding - if i := len(part) % 4; i != 0 { - part += strings.Repeat("=", 4-i) - } - - decodedPart, err := base64.StdEncoding.DecodeString(part) - if err != nil { - interactionId = "Unknown" - } else { - interactionId = strings.Split(string(decodedPart), ":")[1] - } - - bucket.WriteByte('/') - bucket.WriteString(interactionId) - continue - } - - // Strip webhook tokens and interaction tokens - if (currMajor == MajorWebhooks || currMajor == MajorInteractions) && len(part) >= 64 { - bucket.WriteString("/!") - continue - } bucket.WriteByte('/') bucket.WriteString(part) } - return bucket.String() + return bucket.String(), majorParamsHash } diff --git a/lib/bucketpath_test.go b/lib/bucketpath_test.go index 19931d6..a97463b 100644 --- a/lib/bucketpath_test.go +++ b/lib/bucketpath_test.go @@ -47,8 +47,17 @@ func TestPaths(t *testing.T) { // Message delete has multiple buckets // 10 seconds after 2016-01-01 00:00:00 {"/api/v9/channels/1412822759695974551/messages/132271529014534145", "DELETE", "/channels/1412822759695974551/messages/!"}, + // 1 hour before 2016-01-01 00:00:00 + {"/api/v9/channels/1412822759695974551/messages/132256471463174144", "DELETE", "/channels/1412822759695974551/messages/!"}, // 14 days before 2016-01-01 00:00:00 {"/api/v9/channels/1412822759695974551/messages/127198140839174145", "DELETE", "/channels/1412822759695974551/messages/!14dmsg"}, + // Message patch has multiple buckets + // 10 seconds after 2016-01-01 00:00:00 + {"/api/v9/channels/1412822759695974551/messages/132271529014534145", "PATCH", "/channels/1412822759695974551/messages/!"}, + // 1 hour before 2016-01-01 00:00:00 + {"/api/v9/channels/1412822759695974551/messages/132256471463174144", "PATCH", "/channels/1412822759695974551/messages/!1hmsg"}, + // 14 days before 2016-01-01 00:00:00 + {"/api/v9/channels/1412822759695974551/messages/127198140839174145", "PATCH", "/channels/1412822759695974551/messages/!1hmsg"}, } for _, tt := range tests { testname := fmt.Sprintf("%s-%s", tt.method, tt.path) @@ -59,7 +68,7 @@ func TestPaths(t *testing.T) { time.Sleep(140256 * time.Hour) // Time will always be midnight UTC 2016-01-01 00:00:00 - bucket := GetOptimisticBucketPath(tt.path, tt.method) + bucket, _ := GetOptimisticBucketPath(tt.path, tt.method) if bucket != tt.want { t.Errorf("Expected %s but got %s", tt.want, bucket) } diff --git a/lib/queue.go b/lib/queue.go index cef2d2c..a8522a3 100644 --- a/lib/queue.go +++ b/lib/queue.go @@ -204,7 +204,7 @@ func (q *RequestQueue) sweepBuckets() { for key, val := range q.buckets { // This is technically a data race, but we are looking for buckets that are insanely // unused, so we can afford the data race - if val.inTransit == 0 && time.Since(val.increaseAt) > 3*val.period { + if time.Since(val.serverUpdatedAt) > 30*time.Second { delete(q.buckets, key) sweptEntries++ } @@ -237,14 +237,14 @@ func safeSend(queue *QueueChannel, value *QueueItem) { queue.ch <- value } -func (q *RequestQueue) Queue(req *http.Request, res *http.ResponseWriter, path string, pathHash uint64) error { +func (q *RequestQueue) Queue(req *http.Request, res *http.ResponseWriter, path string, pathHash, bucketHash uint64) error { logger.WithFields(logrus.Fields{ "bucket": path, "path": req.URL.Path, "method": req.Method, }).Trace("Inbound request") - ch := q.getQueueChannel(path, pathHash) + ch := q.getQueueChannel(path, pathHash, bucketHash) doneChan := make(chan *http.Response) errChan := make(chan error) @@ -259,7 +259,7 @@ func (q *RequestQueue) Queue(req *http.Request, res *http.ResponseWriter, path s } } -func (q *RequestQueue) getQueueChannel(path string, pathHash uint64) *QueueChannel { +func (q *RequestQueue) getQueueChannel(path string, pathHash, bucketHash uint64) *QueueChannel { t := time.Now() q.Lock() defer q.Unlock() @@ -272,7 +272,7 @@ func (q *RequestQueue) getQueueChannel(path string, pathHash uint64) *QueueChann } q.queues[pathHash] = ch // It's important that we only have 1 goroutine per channel - go q.subscribe(ch, path, pathHash) + go q.subscribe(ch, path, bucketHash) } else { ch.lastUsed = t } @@ -293,7 +293,7 @@ func parseHeaders(headers *http.Header) (string, int64, int64, float64, float64, scope := headers.Get("x-ratelimit-scope") if scope == "" { - scope = "route" + scope = "user" } if resetAfter == "" || (scope != "user" && retryAfter != "") { @@ -405,7 +405,7 @@ func (q *RequestQueue) getBucketsContextManager(ch *QueueChannel) *bucketsContex return contextManager } -func (q *RequestQueue) doRequest(ctx context.Context, item *QueueItem, ch *QueueChannel, buckets *bucketsContextManager, path, pathHash string) { +func (q *RequestQueue) doRequest(ctx context.Context, item *QueueItem, ch *QueueChannel, buckets *bucketsContextManager, path, topBucketHash string) { if buckets != nil { defer func() { buckets.Release() @@ -447,12 +447,11 @@ func (q *RequestQueue) doRequest(ctx context.Context, item *QueueItem, ch *Queue if bucketHash != "" || ratelimitHit { if bucketHash == "" { // We might have hit a Cloudflare 429, so we create a special bucket for that - bucketHash = "route" + bucketHash = "route" + ":" + topBucketHash + } else { + bucketHash = bucketHash + ":" + topBucketHash + ":" + scope } - // Bucket hashes are per path hash - bucketHash += ":" + pathHash - q.Lock() bucket, ok := q.buckets[bucketHash] if !ok { @@ -510,7 +509,7 @@ func (q *RequestQueue) doRequest(ctx context.Context, item *QueueItem, ch *Queue "identifier": q.identifier, "route": item.Req.URL.String(), "method": item.Req.Method, - "pathHash": pathHash, + "pathHash": topBucketHash, // TODO: Remove this when 429s are not a problem anymore "discordBucket": bucketHash, "ratelimitScope": scope, @@ -548,11 +547,11 @@ func (q *RequestQueue) doRequest(ctx context.Context, item *QueueItem, ch *Queue } } -func (q *RequestQueue) subscribe(ch *QueueChannel, path string, pathHashInt uint64) { +func (q *RequestQueue) subscribe(ch *QueueChannel, path string, majorBucketHashInt uint64) { // This function has 1 goroutine for each bucket path // Locking here is not needed - pathHash := strconv.FormatUint(pathHashInt, 10) + majorBucketHash := strconv.FormatUint(majorBucketHashInt, 10) for item := range ch.ch { ctx := context.WithValue(item.Req.Context(), "identifier", q.identifier) @@ -604,11 +603,11 @@ func (q *RequestQueue) subscribe(ch *QueueChannel, path string, pathHashInt uint // If this is a route with no ratelimits, then we will simply execute them all sequentially, // which should be fine // - // TODO: Consider if its worth hard coding which routes will never have a bucket - if buckets == nil || !allowConcurrentRequests { - q.doRequest(ctx, item, ch, buckets, path, pathHash) + // PathHashInt is a special case for "no ratelimits" endpoints + if (buckets == nil && majorBucketHashInt != 0) || !allowConcurrentRequests { + q.doRequest(ctx, item, ch, buckets, path, majorBucketHash) } else { - go q.doRequest(ctx, item, ch, buckets, path, pathHash) + go q.doRequest(ctx, item, ch, buckets, path, majorBucketHash) } } } diff --git a/lib/queue_manager.go b/lib/queue_manager.go index e4789fc..bde2344 100644 --- a/lib/queue_manager.go +++ b/lib/queue_manager.go @@ -254,13 +254,13 @@ func (m *QueueManager) DiscordRequestHandler(resp http.ResponseWriter, req *http defer ConnectionsOpen.With(map[string]string{"route": metricsPath, "method": req.Method}).Dec() token := req.Header.Get("Authorization") - routingHash, path, queueType := m.GetRequestRoutingInfo(req, token) + routingHash, majorBucketHash, path, queueType := m.GetRequestRoutingInfo(req, token) - m.fulfillRequest(&resp, req, queueType, path, routingHash, token, reqStart) + m.fulfillRequest(&resp, req, queueType, path, routingHash, majorBucketHash, token, reqStart) } -func (m *QueueManager) GetRequestRoutingInfo(req *http.Request, token string) (routingHash uint64, path string, queueType QueueType) { - path = GetOptimisticBucketPath(req.URL.Path, req.Method) +func (m *QueueManager) GetRequestRoutingInfo(req *http.Request, token string) (routingHash, majorBucketHash uint64, path string, queueType QueueType) { + path, majorBucketHash = GetOptimisticBucketPath(req.URL.Path, req.Method) queueType = NoAuth if strings.HasPrefix(token, "Bearer") { queueType = Bearer @@ -272,7 +272,7 @@ func (m *QueueManager) GetRequestRoutingInfo(req *http.Request, token string) (r return } -func (m *QueueManager) fulfillRequest(resp *http.ResponseWriter, req *http.Request, queueType QueueType, path string, pathHash uint64, token string, reqStart time.Time) { +func (m *QueueManager) fulfillRequest(resp *http.ResponseWriter, req *http.Request, queueType QueueType, path string, pathHash, majorBucketHash uint64, token string, reqStart time.Time) { logEntry := logger.WithField("clientIp", req.RemoteAddr) forwdFor := req.Header.Get("X-Forwarded-For") if forwdFor != "" { @@ -331,7 +331,7 @@ func (m *QueueManager) fulfillRequest(resp *http.ResponseWriter, req *http.Reque } } } - err = q.Queue(req, resp, path, pathHash) + err = q.Queue(req, resp, path, pathHash, majorBucketHash) if err != nil { log := logEntry.WithField("function", "Queue") if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { From f3b61b9109eeb0ace8c5ec315c6b1da8e49a3c4f Mon Sep 17 00:00:00 2001 From: davfsa Date: Sun, 11 Jan 2026 17:58:52 +0100 Subject: [PATCH 086/105] fix: properly group up CF per route ban --- lib/queue.go | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/lib/queue.go b/lib/queue.go index a8522a3..fa56fb2 100644 --- a/lib/queue.go +++ b/lib/queue.go @@ -237,14 +237,14 @@ func safeSend(queue *QueueChannel, value *QueueItem) { queue.ch <- value } -func (q *RequestQueue) Queue(req *http.Request, res *http.ResponseWriter, path string, pathHash, bucketHash uint64) error { +func (q *RequestQueue) Queue(req *http.Request, res *http.ResponseWriter, path string, pathHash, majorBucketHash uint64) error { logger.WithFields(logrus.Fields{ "bucket": path, "path": req.URL.Path, "method": req.Method, }).Trace("Inbound request") - ch := q.getQueueChannel(path, pathHash, bucketHash) + ch := q.getQueueChannel(path, pathHash, majorBucketHash) doneChan := make(chan *http.Response) errChan := make(chan error) @@ -259,7 +259,7 @@ func (q *RequestQueue) Queue(req *http.Request, res *http.ResponseWriter, path s } } -func (q *RequestQueue) getQueueChannel(path string, pathHash, bucketHash uint64) *QueueChannel { +func (q *RequestQueue) getQueueChannel(path string, pathHash, majorBucketHash uint64) *QueueChannel { t := time.Now() q.Lock() defer q.Unlock() @@ -272,7 +272,7 @@ func (q *RequestQueue) getQueueChannel(path string, pathHash, bucketHash uint64) } q.queues[pathHash] = ch // It's important that we only have 1 goroutine per channel - go q.subscribe(ch, path, bucketHash) + go q.subscribe(ch, path, pathHash, majorBucketHash) } else { ch.lastUsed = t } @@ -405,7 +405,7 @@ func (q *RequestQueue) getBucketsContextManager(ch *QueueChannel) *bucketsContex return contextManager } -func (q *RequestQueue) doRequest(ctx context.Context, item *QueueItem, ch *QueueChannel, buckets *bucketsContextManager, path, topBucketHash string) { +func (q *RequestQueue) doRequest(ctx context.Context, item *QueueItem, ch *QueueChannel, buckets *bucketsContextManager, path, pathHash, topBucketHash string) { if buckets != nil { defer func() { buckets.Release() @@ -447,7 +447,7 @@ func (q *RequestQueue) doRequest(ctx context.Context, item *QueueItem, ch *Queue if bucketHash != "" || ratelimitHit { if bucketHash == "" { // We might have hit a Cloudflare 429, so we create a special bucket for that - bucketHash = "route" + ":" + topBucketHash + bucketHash = "route:" + pathHash } else { bucketHash = bucketHash + ":" + topBucketHash + ":" + scope } @@ -462,7 +462,6 @@ func (q *RequestQueue) doRequest(ctx context.Context, item *QueueItem, ch *Queue "resetAt": resetAt, "resetAfter": resetAfter, "identifier": q.identifier, - "path": path, "route": item.Req.URL.String(), "method": item.Req.Method, }).Debug("creating new bucket") @@ -476,7 +475,6 @@ func (q *RequestQueue) doRequest(ctx context.Context, item *QueueItem, ch *Queue "resetAt": resetAt, "resetAfter": resetAfter, "identifier": q.identifier, - "path": path, "route": item.Req.URL.String(), "method": item.Req.Method, }).Debug("updating existing bucket") @@ -492,7 +490,6 @@ func (q *RequestQueue) doRequest(ctx context.Context, item *QueueItem, ch *Queue "identifier": q.identifier, "route": item.Req.URL.String(), "method": item.Req.Method, - "path": path, }).Debug("linking new bucket to route") ch.buckets = append(ch.buckets, bucketHash) @@ -505,7 +502,6 @@ func (q *RequestQueue) doRequest(ctx context.Context, item *QueueItem, ch *Queue logger.WithFields(logrus.Fields{ "remaining": remaining, "resetAfter": resetAfter, - "bucket": path, "identifier": q.identifier, "route": item.Req.URL.String(), "method": item.Req.Method, @@ -519,7 +515,6 @@ func (q *RequestQueue) doRequest(ctx context.Context, item *QueueItem, ch *Queue if resp.StatusCode == 404 && strings.HasPrefix(path, "/webhooks/") && !isInteraction(item.Req.URL.String()) { logger.WithFields(logrus.Fields{ - "bucket": path, "route": item.Req.URL.String(), "method": item.Req.Method, }).Info("Setting fail fast 404 for webhook") @@ -533,7 +528,6 @@ func (q *RequestQueue) doRequest(ctx context.Context, item *QueueItem, ch *Queue if resp.StatusCode == 401 && !isInteraction(item.Req.URL.String()) && q.queueType != NoAuth { // Permanently lock this queue logger.WithFields(logrus.Fields{ - "bucket": path, "route": item.Req.URL.String(), "method": item.Req.Method, "identifier": q.identifier, @@ -547,11 +541,12 @@ func (q *RequestQueue) doRequest(ctx context.Context, item *QueueItem, ch *Queue } } -func (q *RequestQueue) subscribe(ch *QueueChannel, path string, majorBucketHashInt uint64) { +func (q *RequestQueue) subscribe(ch *QueueChannel, path string, pathHashInt, majorBucketHashInt uint64) { // This function has 1 goroutine for each bucket path // Locking here is not needed majorBucketHash := strconv.FormatUint(majorBucketHashInt, 10) + pathHash := strconv.FormatUint(pathHashInt, 10) for item := range ch.ch { ctx := context.WithValue(item.Req.Context(), "identifier", q.identifier) @@ -605,9 +600,9 @@ func (q *RequestQueue) subscribe(ch *QueueChannel, path string, majorBucketHashI // // PathHashInt is a special case for "no ratelimits" endpoints if (buckets == nil && majorBucketHashInt != 0) || !allowConcurrentRequests { - q.doRequest(ctx, item, ch, buckets, path, majorBucketHash) + q.doRequest(ctx, item, ch, buckets, path, pathHash, majorBucketHash) } else { - go q.doRequest(ctx, item, ch, buckets, path, majorBucketHash) + go q.doRequest(ctx, item, ch, buckets, path, pathHash, majorBucketHash) } } } From 2e582211350e971ec2ddb56f90e7068157e34982 Mon Sep 17 00:00:00 2001 From: davfsa Date: Sun, 11 Jan 2026 18:06:39 +0100 Subject: [PATCH 087/105] chore: group up all interaction callbacks into a single queue --- lib/bucketpath.go | 8 +++++++- lib/bucketpath_test.go | 7 +++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/lib/bucketpath.go b/lib/bucketpath.go index ff9baba..01b4f8e 100644 --- a/lib/bucketpath.go +++ b/lib/bucketpath.go @@ -147,7 +147,7 @@ func GetOptimisticBucketPath(url string, method string) (string, uint64) { case MajorInteractions: if numParts == 4 && parts[3] == "callback" { // Hash 0 is a special case for "no ratelimits" - return "/" + MajorInteractions + "/" + parts[1] + "/!/callback", 0 + return "/" + MajorInteractions + "/!/!/callback", 0 } fallthrough default: @@ -198,6 +198,12 @@ func GetOptimisticBucketPath(url string, method string) (string, uint64) { break } + // Strip webhook tokens and interaction tokens + if (currMajor == MajorWebhooks || currMajor == MajorInteractions) && len(part) >= 64 { + bucket.WriteString("/!") + continue + } + bucket.WriteByte('/') bucket.WriteString(part) } diff --git a/lib/bucketpath_test.go b/lib/bucketpath_test.go index a97463b..f886f22 100644 --- a/lib/bucketpath_test.go +++ b/lib/bucketpath_test.go @@ -28,7 +28,10 @@ func TestPaths(t *testing.T) { // Invites major {"/api/v9/invites/dyno", "GET", "/invites/!"}, // Interactions major - {"/api/v9/interactions/203039963636301824/aW50ZXJhY3Rpb246ODg3NTU5MDA01AY4NTUxNDU0OnZwS3QycDhvREk2aVF3U1BqN2prcXBkRmNqNlp4VEhGRjZvSVlXSGh4WG4yb3l6Z3B6NTBPNVc3OHphV05OULLMOHBMa2RTZmVKd3lzVDA2b2h3OTUxaFJ4QlN0dkxXallPcmhnSHNJb0tSV0M5ZzY1NkN4VGRvemFOSHY4b05c/callback", "GET", "/interactions/203039963636301824/!/callback"}, + {"/api/v9/interactions/203039963636301824/aW50ZXJhY3Rpb246ODg3NTU5MDA01AY4NTUxNDU0OnZwS3QycDhvREk2aVF3U1BqN2prcXBkRmNqNlp4VEhGRjZvSVlXSGh4WG4yb3l6Z3B6NTBPNVc3OHphV05OULLMOHBMa2RTZmVKd3lzVDA2b2h3OTUxaFJ4QlN0dkxXallPcmhnSHNJb0tSV0M5ZzY1NkN4VGRvemFOSHY4b05c/callback", "GET", "/interactions/!/!/callback"}, + // Make sure we dont break future fictional future /interactions + {"/api/v9/interactions/203039963636301824/get-author", "GET", "/interactions/203039963636301824/get-author"}, + {"/api/v9/interactions/203039963636301824/aW50ZXJhY3Rpb246ODg3NTU5MDA01AY4NTUxNDU0OnZwS3QycDhvREk2aVF3U1BqN2prcXBkRmNqNlp4VEhGRjZvSVlXSGh4WG4yb3l6Z3B6NTBPNVc3OHphV05OULLMOHBMa2RTZmVKd3lzVDA2b2h3OTUxaFJ4QlN0dkxXallPcmhnSHNJb0tSV0M5ZzY1NkN4VGRvemFOSHY4b05c/fictional", "GET", "/interactions/203039963636301824/!/fictional"}, // Interaction followup webhooks {"/api/v10/webhooks/203039963636301824/aW50ZXJhY3Rpb246MTEwMzA0OTQyMDkzMDU2ODMyMjpOZUllWHdNU2J4RXBFMHVYRjBpU0pHMDdEb3BhM3ZlYklBODlMUmtlUXlRbzlpZzYyTnpLU0dqdWlyVlBvZnBSUlJHbUJHYlJ0N29MbE9KQUJVTFk4bTR4UzFtZEpEeXJyY0hBUERmTEhKVE9wRkNzU1FFWUkwTnlpWFY2WHdrRg/messages/@original", "POST", "/webhooks/203039963636301824/1103049420930568322/messages/@original"}, // No known major @@ -38,7 +41,7 @@ func TestPaths(t *testing.T) { {"/api/v9/guilds/templates/203039963636301824", "GET", "/guilds/templates/!"}, // Unversioned routes {"/api/webhooks/203039963636301824/VSOzAqY1OZFF5WJVtbIzFtmjGupk-84Hn0A_ZzToF_CHsPIeCk0Q9Uok_mjxR0dNtApI", "POST", "/webhooks/203039963636301824/!"}, - {"/api/interactions/203039963636301824/aW50ZXJhY3Rpb246ODg3NTU5MDA01AY4NTUxNDU0OnZwS3QycDhvREk2aVF3U1BqN2prcXBkRmNqNlp4VEhGRjZvSVlXSGh4WG4yb3l6Z3B6NTBPNVc3OHphV05OULLMOHBMa2RTZmVKd3lzVDA2b2h3OTUxaFJ4QlN0dkxXallPcmhnSHNJb0tSV0M5ZzY1NkN4VGRvemFOSHY4b05c/callback", "GET", "/interactions/203039963636301824/!/callback"}, + {"/api/interactions/203039963636301824/aW50ZXJhY3Rpb246ODg3NTU5MDA01AY4NTUxNDU0OnZwS3QycDhvREk2aVF3U1BqN2prcXBkRmNqNlp4VEhGRjZvSVlXSGh4WG4yb3l6Z3B6NTBPNVc3OHphV05OULLMOHBMa2RTZmVKd3lzVDA2b2h3OTUxaFJ4QlN0dkxXallPcmhnSHNJb0tSV0M5ZzY1NkN4VGRvemFOSHY4b05c/callback", "GET", "/interactions/!/!/callback"}, {"/api/channels/872712139712913438/messages/872712150509047809/reactions/PandaOhShit:863985751205085195", "GET", "/channels/872712139712913438/messages/!/reactions/!/!"}, {"/api/invites/dyno", "GET", "/invites/!"}, // Application commands From 522a2406b3c7a186f531bf75591f7e95022af4df Mon Sep 17 00:00:00 2001 From: davfsa Date: Sun, 11 Jan 2026 18:11:43 +0100 Subject: [PATCH 088/105] feat: improve logging --- lib/queue.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/queue.go b/lib/queue.go index fa56fb2..3c81f18 100644 --- a/lib/queue.go +++ b/lib/queue.go @@ -462,7 +462,7 @@ func (q *RequestQueue) doRequest(ctx context.Context, item *QueueItem, ch *Queue "resetAt": resetAt, "resetAfter": resetAfter, "identifier": q.identifier, - "route": item.Req.URL.String(), + "path": path, "method": item.Req.Method, }).Debug("creating new bucket") @@ -475,7 +475,7 @@ func (q *RequestQueue) doRequest(ctx context.Context, item *QueueItem, ch *Queue "resetAt": resetAt, "resetAfter": resetAfter, "identifier": q.identifier, - "route": item.Req.URL.String(), + "path": path, "method": item.Req.Method, }).Debug("updating existing bucket") @@ -488,7 +488,7 @@ func (q *RequestQueue) doRequest(ctx context.Context, item *QueueItem, ch *Queue logger.WithFields(logrus.Fields{ "bucket": bucketHash, "identifier": q.identifier, - "route": item.Req.URL.String(), + "path": path, "method": item.Req.Method, }).Debug("linking new bucket to route") @@ -505,7 +505,7 @@ func (q *RequestQueue) doRequest(ctx context.Context, item *QueueItem, ch *Queue "identifier": q.identifier, "route": item.Req.URL.String(), "method": item.Req.Method, - "pathHash": topBucketHash, + "path": path, // TODO: Remove this when 429s are not a problem anymore "discordBucket": bucketHash, "ratelimitScope": scope, @@ -598,8 +598,8 @@ func (q *RequestQueue) subscribe(ch *QueueChannel, path string, pathHashInt, maj // If this is a route with no ratelimits, then we will simply execute them all sequentially, // which should be fine // - // PathHashInt is a special case for "no ratelimits" endpoints - if (buckets == nil && majorBucketHashInt != 0) || !allowConcurrentRequests { + // majorBucketHashInt=0 is a special case for "no ratelimits" endpoints + if (buckets == nil || !allowConcurrentRequests) && majorBucketHashInt != 0 { q.doRequest(ctx, item, ch, buckets, path, pathHash, majorBucketHash) } else { go q.doRequest(ctx, item, ch, buckets, path, pathHash, majorBucketHash) From b31cd612630cfbc2eb2e45561911cda360dd49a5 Mon Sep 17 00:00:00 2001 From: davfsa Date: Sun, 11 Jan 2026 19:27:28 +0100 Subject: [PATCH 089/105] fix: avoid spamming metrics with activity-instance IDs --- lib/bucketpath.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/bucketpath.go b/lib/bucketpath.go index 01b4f8e..673f187 100644 --- a/lib/bucketpath.go +++ b/lib/bucketpath.go @@ -46,11 +46,11 @@ func GetMetricsPath(route string) string { return "/invite/!" } - for _, part := range parts { + for idx, part := range parts { if part == "" { continue } - if IsNumericInput(part) { + if IsNumericInput(part) || (idx != 0 && parts[idx-1] == "activity-instances") { path += "/!" } else { path += "/" + part From 7e9e4f0c972bef3ae6ded98ace1c83b877b35022 Mon Sep 17 00:00:00 2001 From: davfsa Date: Sun, 11 Jan 2026 21:36:34 +0100 Subject: [PATCH 090/105] fix: do not distribute interaction callbacks over cluster --- lib/queue_manager.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/queue_manager.go b/lib/queue_manager.go index bde2344..a967968 100644 --- a/lib/queue_manager.go +++ b/lib/queue_manager.go @@ -24,10 +24,12 @@ const ( ) // Some routes that have @me on the path don't really spread out through the cluster, causing issues -// and exacerbating tail latency hits from Discord. Only routes with no ratelimit headers should be put here +// and exacerbating tail latency hits from Discord. Same goes for interaction callbacks. +// Only routes with no ratelimit headers should be put here var pathsToRouteLocally = map[uint64]struct{}{ - HashCRC64("/users/@me/channels"): {}, - HashCRC64("/users/@me"): {}, + HashCRC64("/users/@me/channels"): {}, + HashCRC64("/users/@me"): {}, + HashCRC64("/interactions/!/!/callback"): {}, } type QueueManager struct { From 6bae75f836d99dfdaab254ca16701705b0f7c7de Mon Sep 17 00:00:00 2001 From: davfsa Date: Mon, 12 Jan 2026 21:51:48 +0100 Subject: [PATCH 091/105] fix: do not merge callback endpoints It causes issues that are too rooted into how endpoints are handled, so just split them up for now --- lib/bucketpath.go | 4 ++-- lib/queue.go | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/lib/bucketpath.go b/lib/bucketpath.go index 673f187..e02f405 100644 --- a/lib/bucketpath.go +++ b/lib/bucketpath.go @@ -146,8 +146,8 @@ func GetOptimisticBucketPath(url string, method string) (string, uint64) { } case MajorInteractions: if numParts == 4 && parts[3] == "callback" { - // Hash 0 is a special case for "no ratelimits" - return "/" + MajorInteractions + "/!/!/callback", 0 + majorParamsHash = majorParamHash(MajorInteractions, parts[1], parts[2]) + return "/" + MajorInteractions + "/" + parts[1] + "/!/callback", majorParamsHash } fallthrough default: diff --git a/lib/queue.go b/lib/queue.go index 3c81f18..72d915a 100644 --- a/lib/queue.go +++ b/lib/queue.go @@ -597,9 +597,7 @@ func (q *RequestQueue) subscribe(ch *QueueChannel, path string, pathHashInt, maj // create and populate the bucket when it's known, of it thats what the user wants // If this is a route with no ratelimits, then we will simply execute them all sequentially, // which should be fine - // - // majorBucketHashInt=0 is a special case for "no ratelimits" endpoints - if (buckets == nil || !allowConcurrentRequests) && majorBucketHashInt != 0 { + if buckets == nil || !allowConcurrentRequests { q.doRequest(ctx, item, ch, buckets, path, pathHash, majorBucketHash) } else { go q.doRequest(ctx, item, ch, buckets, path, pathHash, majorBucketHash) From f25cb139b0eb57c6d132eeaf11178d202ed533f7 Mon Sep 17 00:00:00 2001 From: davfsa Date: Mon, 12 Jan 2026 22:30:28 +0100 Subject: [PATCH 092/105] chore: bump deps --- go.mod | 28 ++++++++++++++-------------- go.sum | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 14 deletions(-) diff --git a/go.mod b/go.mod index c234604..c863e48 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,11 @@ module github.com/germanoeich/nirn-proxy go 1.25 require ( - github.com/Clever/leakybucket v1.2.0 + github.com/Clever/leakybucket v1.3.0 github.com/hashicorp/golang-lru v1.0.2 - github.com/hashicorp/memberlist v0.5.3 + github.com/hashicorp/memberlist v0.5.4 github.com/joho/godotenv v1.5.1 - github.com/prometheus/client_golang v1.22.0 + github.com/prometheus/client_golang v1.23.2 github.com/sirupsen/logrus v1.9.3 ) @@ -19,19 +19,19 @@ require ( github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-immutable-radix v1.3.1 // indirect github.com/hashicorp/go-metrics v0.5.4 // indirect - github.com/hashicorp/go-msgpack/v2 v2.1.3 // indirect + github.com/hashicorp/go-msgpack/v2 v2.1.5 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-sockaddr v1.0.7 // indirect - github.com/miekg/dns v1.1.66 // indirect + github.com/miekg/dns v1.1.70 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/prometheus/client_model v0.6.1 // indirect - github.com/prometheus/common v0.62.0 // indirect - github.com/prometheus/procfs v0.15.1 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect - golang.org/x/mod v0.25.0 // indirect - golang.org/x/net v0.41.0 // indirect - golang.org/x/sync v0.15.0 // indirect - golang.org/x/sys v0.33.0 // indirect - golang.org/x/tools v0.34.0 // indirect - google.golang.org/protobuf v1.36.5 // indirect + golang.org/x/mod v0.32.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/tools v0.41.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect ) diff --git a/go.sum b/go.sum index 573d172..9a0aca9 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,8 @@ cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= github.com/Clever/leakybucket v1.2.0 h1:tj9bHR6QS6c5Crszv+EP66NcbJxLabwZ90CUqNlFsSw= github.com/Clever/leakybucket v1.2.0/go.mod h1:gZbI9EI3nNh9loJzrwobjtPUh3fuOT2Q6GgqtBHFuc4= +github.com/Clever/leakybucket v1.3.0 h1:GSj9YT5iTni8MCql6kqWCafX4AFOEytstreD4y3Z8Jc= +github.com/Clever/leakybucket v1.3.0/go.mod h1:oiP7sa8A5USud+zeGa5rkkxPYMFof65eNIik7ohMoPk= github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= @@ -64,6 +66,8 @@ github.com/hashicorp/go-metrics v0.5.4 h1:8mmPiIJkTPPEbAiV97IxdAGNdRdaWwVap1BU6e github.com/hashicorp/go-metrics v0.5.4/go.mod h1:CG5yz4NZ/AI/aQt9Ucm/vdBnbh7fvmv4lxZ350i+QQI= github.com/hashicorp/go-msgpack/v2 v2.1.3 h1:cB1w4Zrk0O3jQBTcFMKqYQWRFfsSQ/TYKNyUUVyCP2c= github.com/hashicorp/go-msgpack/v2 v2.1.3/go.mod h1:SjlwKKFnwBXvxD/I1bEcfJIBbEJ+MCUn39TxymNR5ZU= +github.com/hashicorp/go-msgpack/v2 v2.1.5 h1:Ue879bPnutj/hXfmUk6s/jtIK90XxgiUIcXRl656T44= +github.com/hashicorp/go-msgpack/v2 v2.1.5/go.mod h1:bjCsRXpZ7NsJdk45PoCQnzRGDaK8TKm5ZnDI/9y3J4M= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= @@ -76,6 +80,8 @@ github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iP github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/memberlist v0.5.3 h1:tQ1jOCypD0WvMemw/ZhhtH+PWpzcftQvgCorLu0hndk= github.com/hashicorp/memberlist v0.5.3/go.mod h1:h60o12SZn/ua/j0B6iKAZezA4eDaGsIuPO70eOaJ6WE= +github.com/hashicorp/memberlist v0.5.4 h1:40YY+3qq2tAUhZIMEK8kqusKZBBjdwJ3NUjvYkcxh74= +github.com/hashicorp/memberlist v0.5.4/go.mod h1:OgN6xiIo6RlHUWk+ALjP9e32xWCoQrsOCmHrWCm2MWA= github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= @@ -99,6 +105,10 @@ github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+ github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/miekg/dns v1.1.66 h1:FeZXOS3VCVsKnEAd+wBkjMC3D2K+ww66Cq3VnCINuJE= github.com/miekg/dns v1.1.66/go.mod h1:jGFzBsSNbJw6z1HYut1RKBKHA9PBdxeHrZG8J+gC2WE= +github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA= +github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps= +github.com/miekg/dns v1.1.70 h1:DZ4u2AV35VJxdD9Fo9fIWm119BsQL5cZU1cQ9s0LkqA= +github.com/miekg/dns v1.1.70/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= @@ -121,17 +131,22 @@ github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP github.com/prometheus/client_golang v1.11.1/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= @@ -139,6 +154,7 @@ github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4O github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= @@ -161,6 +177,10 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= +golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -171,6 +191,10 @@ golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81R golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -178,6 +202,10 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -195,6 +223,10 @@ golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= @@ -202,6 +234,10 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= +golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= @@ -213,6 +249,8 @@ google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2 google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From dcce6bc357f4c9201c9e1332cfa054015d6abd2f Mon Sep 17 00:00:00 2001 From: davfsa Date: Tue, 13 Jan 2026 19:19:19 +0100 Subject: [PATCH 093/105] fix: detection of fixed buckets --- go.mod | 1 + go.sum | 69 +++++++++++---------------------------------------- lib/bucket.go | 2 +- 3 files changed, 17 insertions(+), 55 deletions(-) diff --git a/go.mod b/go.mod index c863e48..c0d0148 100644 --- a/go.mod +++ b/go.mod @@ -28,6 +28,7 @@ require ( github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect golang.org/x/mod v0.32.0 // indirect golang.org/x/net v0.49.0 // indirect golang.org/x/sync v0.19.0 // indirect diff --git a/go.sum b/go.sum index 9a0aca9..adfe9a3 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,4 @@ cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -github.com/Clever/leakybucket v1.2.0 h1:tj9bHR6QS6c5Crszv+EP66NcbJxLabwZ90CUqNlFsSw= -github.com/Clever/leakybucket v1.2.0/go.mod h1:gZbI9EI3nNh9loJzrwobjtPUh3fuOT2Q6GgqtBHFuc4= github.com/Clever/leakybucket v1.3.0 h1:GSj9YT5iTni8MCql6kqWCafX4AFOEytstreD4y3Z8Jc= github.com/Clever/leakybucket v1.3.0/go.mod h1:oiP7sa8A5USud+zeGa5rkkxPYMFof65eNIik7ohMoPk= github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= @@ -11,7 +9,6 @@ github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRF github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA= github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4= -github.com/aws/aws-sdk-go v1.29.31/go.mod h1:1KvfttTE3SPKMpo8g2c6jL3ZKfXtFvKscTgahTma5Xg= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -24,15 +21,12 @@ github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/eapache/go-resiliency v1.2.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= -github.com/garyburd/redigo v1.3.0/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= -github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -64,8 +58,6 @@ github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJ github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-metrics v0.5.4 h1:8mmPiIJkTPPEbAiV97IxdAGNdRdaWwVap1BU6elejKY= github.com/hashicorp/go-metrics v0.5.4/go.mod h1:CG5yz4NZ/AI/aQt9Ucm/vdBnbh7fvmv4lxZ350i+QQI= -github.com/hashicorp/go-msgpack/v2 v2.1.3 h1:cB1w4Zrk0O3jQBTcFMKqYQWRFfsSQ/TYKNyUUVyCP2c= -github.com/hashicorp/go-msgpack/v2 v2.1.3/go.mod h1:SjlwKKFnwBXvxD/I1bEcfJIBbEJ+MCUn39TxymNR5ZU= github.com/hashicorp/go-msgpack/v2 v2.1.5 h1:Ue879bPnutj/hXfmUk6s/jtIK90XxgiUIcXRl656T44= github.com/hashicorp/go-msgpack/v2 v2.1.5/go.mod h1:bjCsRXpZ7NsJdk45PoCQnzRGDaK8TKm5ZnDI/9y3J4M= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= @@ -78,11 +70,8 @@ github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/b github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= -github.com/hashicorp/memberlist v0.5.3 h1:tQ1jOCypD0WvMemw/ZhhtH+PWpzcftQvgCorLu0hndk= -github.com/hashicorp/memberlist v0.5.3/go.mod h1:h60o12SZn/ua/j0B6iKAZezA4eDaGsIuPO70eOaJ6WE= github.com/hashicorp/memberlist v0.5.4 h1:40YY+3qq2tAUhZIMEK8kqusKZBBjdwJ3NUjvYkcxh74= github.com/hashicorp/memberlist v0.5.4/go.mod h1:OgN6xiIo6RlHUWk+ALjP9e32xWCoQrsOCmHrWCm2MWA= -github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= @@ -98,15 +87,14 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxv github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= -github.com/miekg/dns v1.1.66 h1:FeZXOS3VCVsKnEAd+wBkjMC3D2K+ww66Cq3VnCINuJE= -github.com/miekg/dns v1.1.66/go.mod h1:jGFzBsSNbJw6z1HYut1RKBKHA9PBdxeHrZG8J+gC2WE= -github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA= -github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps= github.com/miekg/dns v1.1.70 h1:DZ4u2AV35VJxdD9Fo9fIWm119BsQL5cZU1cQ9s0LkqA= github.com/miekg/dns v1.1.70/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -129,22 +117,17 @@ github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5Fsn github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= github.com/prometheus/client_golang v1.11.1/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= -github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= -github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= -github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= -github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= -github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= @@ -152,9 +135,10 @@ github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsT github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= -github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= -github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= @@ -167,18 +151,17 @@ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= -golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= -golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= -golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -186,13 +169,7 @@ golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73r golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= -golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -200,10 +177,6 @@ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= -golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -216,26 +189,14 @@ golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= -golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= -golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= -golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -247,13 +208,13 @@ google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miE google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= -google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/lib/bucket.go b/lib/bucket.go index 5a539cd..aa0c515 100644 --- a/lib/bucket.go +++ b/lib/bucket.go @@ -226,8 +226,8 @@ func (b *Bucket) Update(remaining, limit int64, resetAt, resetAfter float64, rat } if b.firstSeen && !b.outOfSync && remaining > 0 && remaining != limit-1 { - resetAtEq := isClose(float64(b.resetAt.UnixMilli()/1_000_000), resetAt, 0.05) b.firstSeen = false + resetAtEq := isClose(float64(b.resetAt.UnixMilli())/1_000, resetAt, 0.05) if !b.fixedWindow && resetAtEq { logger.WithFields(logrus.Fields{ From 48a6f9db12f4f688b33780a4cd62ebe2014a1948 Mon Sep 17 00:00:00 2001 From: davfsa Date: Wed, 14 Jan 2026 17:03:36 +0100 Subject: [PATCH 094/105] chore: small optimization --- lib/bucket.go | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/lib/bucket.go b/lib/bucket.go index aa0c515..2490a9b 100644 --- a/lib/bucket.go +++ b/lib/bucket.go @@ -102,10 +102,9 @@ func (b *Bucket) isRatelimited(now time.Time) bool { b.outOfSync = true } else { // Slide window along - gain := int64(math.Floor((now.Sub(b.increaseAt).Seconds())/b.period.Seconds())) + 1 - nowRemaining := b.remaining + gain + gain := int64(math.Ceil((now.Sub(b.increaseAt).Seconds()) / b.period.Seconds())) - b.remaining = min(nowRemaining, b.limit) + b.remaining = min(b.remaining+gain, b.limit) if b.remaining == b.limit { // When a ratelimit resets, we will fall out of sync from the remote, so @@ -218,7 +217,7 @@ func (b *Bucket) Update(remaining, limit int64, resetAt, resetAfter float64, rat if ratelimitHit { // During ratelimit avoidance, we will treat the bucket as fixed // bucket and wait for it to fill up completely - _, b.increaseAt = calculateFixedWindow(resetAt, resetAfter) + b.increaseAt = resetAtTime b.resetAt = resetAtTime b.remaining = 0 b.outOfSync = false @@ -229,25 +228,31 @@ func (b *Bucket) Update(remaining, limit int64, resetAt, resetAfter float64, rat b.firstSeen = false resetAtEq := isClose(float64(b.resetAt.UnixMilli())/1_000, resetAt, 0.05) - if !b.fixedWindow && resetAtEq { + if resetAtEq { logger.WithFields(logrus.Fields{ "bucket": b.bucket, "storedResetAt": b.resetAt, "receivedResetAt": resetAt, }).Debug("bucket detected to be a fixed bucket") - b.fixedWindow = true - // Setting this here will have an effect below - b.outOfSync = true - } else if b.fixedWindow && !resetAtEq { + if !b.fixedWindow { + b.fixedWindow = true + // Setting this here will have an effect below + b.outOfSync = true + } + + } else { logger.WithFields(logrus.Fields{ "bucket": b.bucket, "storedResetAt": b.resetAt, "receivedResetAt": resetAt, }).Debug("bucket detected to be a sliding bucket") - b.fixedWindow = false - // Setting this here will have an effect below - b.outOfSync = true + + if b.fixedWindow { + b.fixedWindow = false + // Setting this here will have an effect below + b.outOfSync = true + } } } From 8491aba40d13be55c8f97356a405187c8be8b16f Mon Sep 17 00:00:00 2001 From: davfsa Date: Wed, 14 Jan 2026 23:15:37 +0100 Subject: [PATCH 095/105] chore: revert some old behavior and prevent period increase --- lib/bucket.go | 93 +++++++++++++++++++++++++++++---------------------- 1 file changed, 53 insertions(+), 40 deletions(-) diff --git a/lib/bucket.go b/lib/bucket.go index 2490a9b..2f15c5e 100644 --- a/lib/bucket.go +++ b/lib/bucket.go @@ -29,6 +29,10 @@ func calculateSlidingWindow(remaining, limit int64, resetAfter float64) (time.Du return slidePeriod, increaseAt } +func isFirstValidHeaders(remaining, limit int64) bool { + return remaining == limit-1 && remaining > 0 && limit != 1 +} + // Bucket is a Discord bucket ratelimiter type Bucket struct { increaseAt time.Time @@ -48,38 +52,39 @@ type Bucket struct { inTransitLock sync.Mutex acquireLock sync.Mutex - outOfSync bool - fixedWindow bool - firstSeen bool + outOfSync bool + fixedWindow bool + typeChangeAllowed bool } func NewBucket(bucket string, remaining, limit int64, resetAt, resetAfter float64) *Bucket { - var period time.Duration - var increaseAt time.Time - var fixedWindow bool + b := &Bucket{ + bucket: bucket, + remaining: remaining, + limit: limit, + resetAt: time.Unix(0, int64(resetAt*1_000_000_000)), + outOfSync: false, + typeChangeAllowed: true, + } - if limit != 1 && remaining == limit-1 { + if isFirstValidHeaders(remaining, limit) { // We have the perfect condition for a sliding window, so assume that for now. // Turning it into a fixed bucket later is preferable, as we might never get this chance again - period, increaseAt = calculateSlidingWindow(remaining, limit, resetAfter) - fixedWindow = false + b.period, b.increaseAt = calculateSlidingWindow(remaining, limit, resetAfter) + b.fixedWindow = false } else { // We can assume its a fixed bucket for now, and hope that in the future we will get // the ideal condition - period, increaseAt = calculateFixedWindow(resetAt, resetAfter) - fixedWindow = true - } + b.period, b.increaseAt = calculateFixedWindow(resetAt, resetAfter) + b.fixedWindow = true - return &Bucket{ - bucket: bucket, - remaining: remaining, - limit: limit, - resetAt: time.Unix(0, int64(resetAt*1_000_000_000)), - period: period, - increaseAt: increaseAt, - fixedWindow: fixedWindow, - firstSeen: true, + if limit == 1 { + // Bucket is 100% a fixed bucket + b.typeChangeAllowed = false + } } + + return b } // Warning: this MUST be called from a locked state @@ -102,7 +107,7 @@ func (b *Bucket) isRatelimited(now time.Time) bool { b.outOfSync = true } else { // Slide window along - gain := int64(math.Ceil((now.Sub(b.increaseAt).Seconds()) / b.period.Seconds())) + gain := int64(math.Floor((now.Sub(b.increaseAt).Seconds())/b.period.Seconds())) + 1 b.remaining = min(b.remaining+gain, b.limit) @@ -210,10 +215,15 @@ func (b *Bucket) Update(remaining, limit int64, resetAt, resetAfter float64, rat resetAfterDuration := time.Duration(resetAfter*1_000) * time.Millisecond serverUpdatedAt := resetAtTime.Add(-resetAfterDuration) - if b.serverUpdatedAt.Before(serverUpdatedAt) { - b.serverUpdatedAt = serverUpdatedAt + if b.serverUpdatedAt.After(serverUpdatedAt) { + // Old ratelimit information, ignore + return } + b.serverUpdatedAt = serverUpdatedAt + + firstValidHeaders := isFirstValidHeaders(remaining, limit) + if ratelimitHit { // During ratelimit avoidance, we will treat the bucket as fixed // bucket and wait for it to fill up completely @@ -224,8 +234,8 @@ func (b *Bucket) Update(remaining, limit int64, resetAt, resetAfter float64, rat return } - if b.firstSeen && !b.outOfSync && remaining > 0 && remaining != limit-1 { - b.firstSeen = false + if b.typeChangeAllowed && !b.outOfSync && !firstValidHeaders { + b.typeChangeAllowed = false resetAtEq := isClose(float64(b.resetAt.UnixMilli())/1_000, resetAt, 0.05) if resetAtEq { @@ -237,8 +247,7 @@ func (b *Bucket) Update(remaining, limit int64, resetAt, resetAfter float64, rat if !b.fixedWindow { b.fixedWindow = true - // Setting this here will have an effect below - b.outOfSync = true + b.period, b.increaseAt = calculateFixedWindow(resetAt, resetAfter) } } else { @@ -250,27 +259,31 @@ func (b *Bucket) Update(remaining, limit int64, resetAt, resetAfter float64, rat if b.fixedWindow { b.fixedWindow = false - // Setting this here will have an effect below - b.outOfSync = true + b.period, b.increaseAt = calculateSlidingWindow(remaining, limit, resetAfter) } } } - if b.resetAt.Before(resetAtTime) { - b.resetAt = resetAtTime - } + b.resetAt = resetAtTime + + if b.outOfSync || firstValidHeaders { + var period time.Duration + var increaseAt time.Time - if b.outOfSync || (limit != 1 && remaining == limit-1) { if b.fixedWindow { - period, increaseAt := calculateFixedWindow(resetAt, resetAfter) - b.period = period - b.increaseAt = increaseAt + period, increaseAt = calculateFixedWindow(resetAt, resetAfter) } else { - period, increaseAt := calculateSlidingWindow(remaining, limit, resetAfter) - b.period = period - b.increaseAt = increaseAt + period, increaseAt = calculateSlidingWindow(remaining, limit, resetAfter) } b.outOfSync = false + + // Prevent both from decreasing + if b.increaseAt.Before(increaseAt) { + b.increaseAt = increaseAt + } + if period > b.period { + b.period = period + } } } From 057489ca19c682073bb1462f4248dff479920a2b Mon Sep 17 00:00:00 2001 From: davfsa Date: Thu, 15 Jan 2026 09:59:38 +0100 Subject: [PATCH 096/105] chore: prevent resetAt and increaseAt from sliding backwards --- lib/bucket.go | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/bucket.go b/lib/bucket.go index 2f15c5e..cb140d0 100644 --- a/lib/bucket.go +++ b/lib/bucket.go @@ -227,8 +227,12 @@ func (b *Bucket) Update(remaining, limit int64, resetAt, resetAfter float64, rat if ratelimitHit { // During ratelimit avoidance, we will treat the bucket as fixed // bucket and wait for it to fill up completely - b.increaseAt = resetAtTime - b.resetAt = resetAtTime + if b.increaseAt.Before(resetAtTime) { + b.increaseAt = resetAtTime + } + if b.resetAt.Before(resetAtTime) { + b.resetAt = resetAtTime + } b.remaining = 0 b.outOfSync = false return @@ -264,7 +268,9 @@ func (b *Bucket) Update(remaining, limit int64, resetAt, resetAfter float64, rat } } - b.resetAt = resetAtTime + if b.resetAt.Before(resetAtTime) { + b.resetAt = resetAtTime + } if b.outOfSync || firstValidHeaders { var period time.Duration From 229af11523a83bb620590c7b8ac55444b98eb249 Mon Sep 17 00:00:00 2001 From: davfsa Date: Thu, 29 Jan 2026 14:24:50 +0100 Subject: [PATCH 097/105] fix: prevent path traversal at proxy level --- lib/queue_manager.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/lib/queue_manager.go b/lib/queue_manager.go index a967968..1fc12e6 100644 --- a/lib/queue_manager.go +++ b/lib/queue_manager.go @@ -249,8 +249,27 @@ func (m *QueueManager) getOrCreateBearerQueue(token string) (*RequestQueue, erro return q.(*RequestQueue), nil } +func isPathTraversal(path string) bool { + segments := strings.Split(path, "/") + for _, segment := range segments { + if segment == ".." { + return true + } + } + return false +} + func (m *QueueManager) DiscordRequestHandler(resp http.ResponseWriter, req *http.Request) { reqStart := time.Now() + + if isPathTraversal(req.URL.Path) { + logger.WithFields(logrus.Fields{"method": req.Method, "url": req.URL.RawPath}).Warn("path traversal detected, dropping request") + resp.Header().Set("generated-by-proxy", "true") + resp.Header().Set("reason", "path traversal") + resp.WriteHeader(400) + return + } + metricsPath := GetMetricsPath(req.URL.Path) ConnectionsOpen.With(map[string]string{"route": metricsPath, "method": req.Method}).Inc() defer ConnectionsOpen.With(map[string]string{"route": metricsPath, "method": req.Method}).Dec() From 25ae3efb302cfccc4e1a1c6cae547c1610186c68 Mon Sep 17 00:00:00 2001 From: davfsa Date: Thu, 29 Jan 2026 14:31:51 +0100 Subject: [PATCH 098/105] chore: use 422 instead of 400 for path traversal --- lib/queue_manager.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/queue_manager.go b/lib/queue_manager.go index 1fc12e6..ca10fe4 100644 --- a/lib/queue_manager.go +++ b/lib/queue_manager.go @@ -266,7 +266,7 @@ func (m *QueueManager) DiscordRequestHandler(resp http.ResponseWriter, req *http logger.WithFields(logrus.Fields{"method": req.Method, "url": req.URL.RawPath}).Warn("path traversal detected, dropping request") resp.Header().Set("generated-by-proxy", "true") resp.Header().Set("reason", "path traversal") - resp.WriteHeader(400) + resp.WriteHeader(422) return } From 66036736a5a54754496e3ce8ed90d314eaab2dd4 Mon Sep 17 00:00:00 2001 From: davfsa Date: Sun, 1 Feb 2026 11:48:54 +0000 Subject: [PATCH 099/105] fix: increase inactive bucket sweep time + properly calculate bucket first valid headers check --- lib/bucket.go | 2 +- lib/queue.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/bucket.go b/lib/bucket.go index cb140d0..018767d 100644 --- a/lib/bucket.go +++ b/lib/bucket.go @@ -30,7 +30,7 @@ func calculateSlidingWindow(remaining, limit int64, resetAfter float64) (time.Du } func isFirstValidHeaders(remaining, limit int64) bool { - return remaining == limit-1 && remaining > 0 && limit != 1 + return remaining == limit-1 && limit != 1 } // Bucket is a Discord bucket ratelimiter diff --git a/lib/queue.go b/lib/queue.go index 72d915a..0b5b310 100644 --- a/lib/queue.go +++ b/lib/queue.go @@ -204,7 +204,7 @@ func (q *RequestQueue) sweepBuckets() { for key, val := range q.buckets { // This is technically a data race, but we are looking for buckets that are insanely // unused, so we can afford the data race - if time.Since(val.serverUpdatedAt) > 30*time.Second { + if time.Since(val.serverUpdatedAt) > 1*time.Minute { delete(q.buckets, key) sweptEntries++ } From ed21f1c6fb0ac022e9e919355c5f882fb3b621a4 Mon Sep 17 00:00:00 2001 From: davfsa Date: Sun, 1 Feb 2026 17:11:18 +0000 Subject: [PATCH 100/105] fix: do not drop old ratelimit information --- lib/bucket.go | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/lib/bucket.go b/lib/bucket.go index 018767d..f058a0d 100644 --- a/lib/bucket.go +++ b/lib/bucket.go @@ -215,13 +215,10 @@ func (b *Bucket) Update(remaining, limit int64, resetAt, resetAfter float64, rat resetAfterDuration := time.Duration(resetAfter*1_000) * time.Millisecond serverUpdatedAt := resetAtTime.Add(-resetAfterDuration) - if b.serverUpdatedAt.After(serverUpdatedAt) { - // Old ratelimit information, ignore - return + if b.serverUpdatedAt.Before(serverUpdatedAt) { + b.serverUpdatedAt = serverUpdatedAt } - b.serverUpdatedAt = serverUpdatedAt - firstValidHeaders := isFirstValidHeaders(remaining, limit) if ratelimitHit { From bbe33dfb951b2282d30817e29611b42b3abb8af1 Mon Sep 17 00:00:00 2001 From: davfsa Date: Sun, 1 Feb 2026 17:16:14 +0000 Subject: [PATCH 101/105] chore: cleanup bucket last updated at --- lib/bucket.go | 19 +++++++------------ lib/queue.go | 2 +- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/lib/bucket.go b/lib/bucket.go index f058a0d..0668705 100644 --- a/lib/bucket.go +++ b/lib/bucket.go @@ -39,12 +39,12 @@ type Bucket struct { transitWaitChan chan interface{} // under stateLock - bucket string - remaining int64 - limit int64 - period time.Duration - resetAt time.Time - serverUpdatedAt time.Time + bucket string + remaining int64 + limit int64 + period time.Duration + resetAt time.Time + lastUpdatedAt time.Time // under inTransitLock inTransit int64 @@ -211,13 +211,8 @@ func (b *Bucket) Update(remaining, limit int64, resetAt, resetAfter float64, rat b.stateLock.Lock() defer b.stateLock.Unlock() + b.lastUpdatedAt = time.Now() resetAtTime := time.Unix(0, int64(resetAt*1_000_000_000)) - resetAfterDuration := time.Duration(resetAfter*1_000) * time.Millisecond - serverUpdatedAt := resetAtTime.Add(-resetAfterDuration) - - if b.serverUpdatedAt.Before(serverUpdatedAt) { - b.serverUpdatedAt = serverUpdatedAt - } firstValidHeaders := isFirstValidHeaders(remaining, limit) diff --git a/lib/queue.go b/lib/queue.go index 0b5b310..afbe79e 100644 --- a/lib/queue.go +++ b/lib/queue.go @@ -204,7 +204,7 @@ func (q *RequestQueue) sweepBuckets() { for key, val := range q.buckets { // This is technically a data race, but we are looking for buckets that are insanely // unused, so we can afford the data race - if time.Since(val.serverUpdatedAt) > 1*time.Minute { + if time.Since(val.lastUpdatedAt) > 1*time.Minute { delete(q.buckets, key) sweptEntries++ } From 48a08fdd28a962ae6780b454b903355e2db0bcbf Mon Sep 17 00:00:00 2001 From: davfsa Date: Sun, 1 Feb 2026 20:13:55 +0000 Subject: [PATCH 102/105] feat: add nanosecond precision to log messages --- main.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/main.go b/main.go index b6efd47..f3ede1e 100644 --- a/main.go +++ b/main.go @@ -25,13 +25,18 @@ var bufferSize = 50 func setupLogger() { logLevel := lib.EnvGet("LOG_LEVEL", "info") - lvl, err := logrus.ParseLevel(logLevel) + lvl, err := logrus.ParseLevel(logLevel) if err != nil { panic("Failed to parse log level") } + logger.SetFormatter(&logrus.TextFormatter{ + FullTimestamp: true, + TimestampFormat: time.RFC3339Nano, + }) logger.SetLevel(lvl) + lib.SetLogger(logger) } From 5467daa418cda63845adab48285559ad4a003abc Mon Sep 17 00:00:00 2001 From: davfsa Date: Sun, 1 Feb 2026 20:14:29 +0000 Subject: [PATCH 103/105] fix: avoid immediately collecting newly created buckets --- lib/bucket.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/bucket.go b/lib/bucket.go index 0668705..8b098eb 100644 --- a/lib/bucket.go +++ b/lib/bucket.go @@ -65,6 +65,7 @@ func NewBucket(bucket string, remaining, limit int64, resetAt, resetAfter float6 resetAt: time.Unix(0, int64(resetAt*1_000_000_000)), outOfSync: false, typeChangeAllowed: true, + lastUpdatedAt: time.Now(), } if isFirstValidHeaders(remaining, limit) { @@ -214,8 +215,6 @@ func (b *Bucket) Update(remaining, limit int64, resetAt, resetAfter float64, rat b.lastUpdatedAt = time.Now() resetAtTime := time.Unix(0, int64(resetAt*1_000_000_000)) - firstValidHeaders := isFirstValidHeaders(remaining, limit) - if ratelimitHit { // During ratelimit avoidance, we will treat the bucket as fixed // bucket and wait for it to fill up completely @@ -230,6 +229,8 @@ func (b *Bucket) Update(remaining, limit int64, resetAt, resetAfter float64, rat return } + firstValidHeaders := isFirstValidHeaders(remaining, limit) + if b.typeChangeAllowed && !b.outOfSync && !firstValidHeaders { b.typeChangeAllowed = false resetAtEq := isClose(float64(b.resetAt.UnixMilli())/1_000, resetAt, 0.05) @@ -238,7 +239,7 @@ func (b *Bucket) Update(remaining, limit int64, resetAt, resetAfter float64, rat logger.WithFields(logrus.Fields{ "bucket": b.bucket, "storedResetAt": b.resetAt, - "receivedResetAt": resetAt, + "receivedResetAt": resetAtTime, }).Debug("bucket detected to be a fixed bucket") if !b.fixedWindow { @@ -250,7 +251,7 @@ func (b *Bucket) Update(remaining, limit int64, resetAt, resetAfter float64, rat logger.WithFields(logrus.Fields{ "bucket": b.bucket, "storedResetAt": b.resetAt, - "receivedResetAt": resetAt, + "receivedResetAt": resetAtTime, }).Debug("bucket detected to be a sliding bucket") if b.fixedWindow { From 62a101d125af1246effd1babba111f993a9699fb Mon Sep 17 00:00:00 2001 From: davfsa Date: Sun, 8 Feb 2026 17:28:45 +0000 Subject: [PATCH 104/105] fix: reduce number of 429s --- lib/bucket.go | 58 +++++++++++++++++++++++++++++++------------- lib/queue.go | 34 ++++++++++++++------------ lib/queue_manager.go | 2 +- 3 files changed, 60 insertions(+), 34 deletions(-) diff --git a/lib/bucket.go b/lib/bucket.go index 8b098eb..7edc17f 100644 --- a/lib/bucket.go +++ b/lib/bucket.go @@ -55,6 +55,7 @@ type Bucket struct { outOfSync bool fixedWindow bool typeChangeAllowed bool + closedChan chan struct{} } func NewBucket(bucket string, remaining, limit int64, resetAt, resetAfter float64) *Bucket { @@ -62,8 +63,8 @@ func NewBucket(bucket string, remaining, limit int64, resetAt, resetAfter float6 bucket: bucket, remaining: remaining, limit: limit, - resetAt: time.Unix(0, int64(resetAt*1_000_000_000)), outOfSync: false, + closedChan: make(chan struct{}, 1), typeChangeAllowed: true, lastUpdatedAt: time.Now(), } @@ -73,11 +74,13 @@ func NewBucket(bucket string, remaining, limit int64, resetAt, resetAfter float6 // Turning it into a fixed bucket later is preferable, as we might never get this chance again b.period, b.increaseAt = calculateSlidingWindow(remaining, limit, resetAfter) b.fixedWindow = false + b.resetAt = time.Now().Add(b.period * time.Duration(b.limit)) } else { // We can assume its a fixed bucket for now, and hope that in the future we will get // the ideal condition b.period, b.increaseAt = calculateFixedWindow(resetAt, resetAfter) b.fixedWindow = true + b.resetAt = time.Unix(0, int64(resetAt*1_000_000_000)) if limit == 1 { // Bucket is 100% a fixed bucket @@ -89,11 +92,13 @@ func NewBucket(bucket string, remaining, limit int64, resetAt, resetAfter float6 } // Warning: this MUST be called from a locked state +// Warning: `now` must be the current time. Passing a present or past value is undefined behaviour func (b *Bucket) isRatelimited(now time.Time) bool { canIncrease := now.After(b.increaseAt) canReset := now.After(b.resetAt) - if (canIncrease && !b.outOfSync) || canReset { + // inTransit check is performed to avoid deadlocks + if (canIncrease && !b.outOfSync && b.inTransit != 1) || canReset { if b.fixedWindow { // Fixed windows just reset the remaining back to the limit b.remaining = b.limit @@ -132,6 +137,9 @@ func (b *Bucket) isRatelimited(now time.Time) bool { func (b *Bucket) Acquire(ctx context.Context) error { b.acquireLock.Lock() defer b.acquireLock.Unlock() + if b.closedChan == nil { + return nil + } b.inTransitLock.Lock() if b.inTransit >= b.limit { @@ -139,6 +147,8 @@ func (b *Bucket) Acquire(ctx context.Context) error { b.transitWaitChan = make(chan interface{}, 1) b.inTransitLock.Unlock() select { + case <-b.closedChan: + break case <-ctx.Done(): b.inTransitLock.Lock() if b.transitWaitChan != nil { @@ -179,6 +189,8 @@ func (b *Bucket) Acquire(ctx context.Context) error { } select { + case <-b.closedChan: + break case <-ctx.Done(): b.Release() return ctx.Err() @@ -191,6 +203,7 @@ func (b *Bucket) Acquire(ctx context.Context) error { return nil } +// Release returns the slot to the bucket func (b *Bucket) Release() { b.inTransitLock.Lock() defer b.inTransitLock.Unlock() @@ -208,6 +221,7 @@ func (b *Bucket) Release() { } } +// Update updates the bucket with new ratelimit information func (b *Bucket) Update(remaining, limit int64, resetAt, resetAfter float64, ratelimitHit bool) { b.stateLock.Lock() defer b.stateLock.Unlock() @@ -218,12 +232,8 @@ func (b *Bucket) Update(remaining, limit int64, resetAt, resetAfter float64, rat if ratelimitHit { // During ratelimit avoidance, we will treat the bucket as fixed // bucket and wait for it to fill up completely - if b.increaseAt.Before(resetAtTime) { - b.increaseAt = resetAtTime - } - if b.resetAt.Before(resetAtTime) { - b.resetAt = resetAtTime - } + b.increaseAt = resetAtTime + b.resetAt = resetAtTime b.remaining = 0 b.outOfSync = false return @@ -231,7 +241,7 @@ func (b *Bucket) Update(remaining, limit int64, resetAt, resetAfter float64, rat firstValidHeaders := isFirstValidHeaders(remaining, limit) - if b.typeChangeAllowed && !b.outOfSync && !firstValidHeaders { + if b.typeChangeAllowed && !b.outOfSync && !firstValidHeaders && remaining > 0 { b.typeChangeAllowed = false resetAtEq := isClose(float64(b.resetAt.UnixMilli())/1_000, resetAt, 0.05) @@ -244,7 +254,8 @@ func (b *Bucket) Update(remaining, limit int64, resetAt, resetAfter float64, rat if !b.fixedWindow { b.fixedWindow = true - b.period, b.increaseAt = calculateFixedWindow(resetAt, resetAfter) + // Setting this here will have an effect below + b.outOfSync = true } } else { @@ -256,14 +267,13 @@ func (b *Bucket) Update(remaining, limit int64, resetAt, resetAfter float64, rat if b.fixedWindow { b.fixedWindow = false - b.period, b.increaseAt = calculateSlidingWindow(remaining, limit, resetAfter) + // Setting this here will have an effect below + b.outOfSync = true } } } - if b.resetAt.Before(resetAtTime) { - b.resetAt = resetAtTime - } + b.resetAt = resetAtTime if b.outOfSync || firstValidHeaders { var period time.Duration @@ -281,8 +291,22 @@ func (b *Bucket) Update(remaining, limit int64, resetAt, resetAfter float64, rat if b.increaseAt.Before(increaseAt) { b.increaseAt = increaseAt } - if period > b.period { - b.period = period - } + b.period = period + } +} + +// Close marks the bucket as closed and immediately returns from any and future Acquire calls. +// The bucket should not be used from this point onwards +func (b *Bucket) Close() { + if b.closedChan == nil { + return } + + logger.WithFields(logrus.Fields{ + "bucket": b.bucket, + }).Debug("bucket closed") + + closedChan := b.closedChan + b.closedChan = nil + closedChan <- struct{}{} } diff --git a/lib/queue.go b/lib/queue.go index afbe79e..a9f1b15 100644 --- a/lib/queue.go +++ b/lib/queue.go @@ -184,7 +184,7 @@ func (q *RequestQueue) destroy() { func (q *RequestQueue) sweepQueues() { q.Lock() defer q.Unlock() - logger.Info("Queues Sweep start") + logger.Info("Queues sweep start") sweptEntries := 0 for key, val := range q.queues { if time.Since(val.lastUsed) > 10*time.Minute { @@ -206,6 +206,7 @@ func (q *RequestQueue) sweepBuckets() { // unused, so we can afford the data race if time.Since(val.lastUpdatedAt) > 1*time.Minute { delete(q.buckets, key) + val.Close() sweptEntries++ } } @@ -440,9 +441,8 @@ func (q *RequestQueue) doRequest(ctx context.Context, item *QueueItem, ch *Queue return } - // TODO: Consider handling special retry case for POST /users/@me/channels - ratelimitHit := resp.StatusCode == 429 + multiBucket := false if bucketHash != "" || ratelimitHit { if bucketHash == "" { @@ -486,29 +486,31 @@ func (q *RequestQueue) doRequest(ctx context.Context, item *QueueItem, ch *Queue ch.Lock() if !slices.Contains(ch.buckets, bucketHash) { logger.WithFields(logrus.Fields{ - "bucket": bucketHash, - "identifier": q.identifier, - "path": path, - "method": item.Req.Method, + "bucket": bucketHash, + "identifier": q.identifier, + "path": path, + "method": item.Req.Method, + "additionalBuckets": ch.buckets, }).Debug("linking new bucket to route") ch.buckets = append(ch.buckets, bucketHash) } + multiBucket = len(ch.buckets) > 1 ch.Unlock() } if ratelimitHit && scope != "shared" { logger.WithFields(logrus.Fields{ - "remaining": remaining, - "resetAfter": resetAfter, - "identifier": q.identifier, - "route": item.Req.URL.String(), - "method": item.Req.Method, - "path": path, - // TODO: Remove this when 429s are not a problem anymore - "discordBucket": bucketHash, - "ratelimitScope": scope, + "remaining": remaining, + "resetAfter": resetAfter, + "identifier": q.identifier, + "route": item.Req.URL.String(), + "method": item.Req.Method, + "path": path, + "discordBucket": bucketHash, + "ratelimitScope": scope, + "multipleBucketsInPath": multiBucket, }).Warn("Unexpected 429") return } diff --git a/lib/queue_manager.go b/lib/queue_manager.go index ca10fe4..f40a856 100644 --- a/lib/queue_manager.go +++ b/lib/queue_manager.go @@ -288,7 +288,7 @@ func (m *QueueManager) GetRequestRoutingInfo(req *http.Request, token string) (r routingHash = HashCRC64(token) } else { queueType = Bot - routingHash = HashCRC64(path) + routingHash = HashCRC64(req.Method + path) } return } From 1a5ae4c7377ff9fe340115442efd3b664608d4b9 Mon Sep 17 00:00:00 2001 From: davfsa Date: Sat, 14 Feb 2026 12:09:58 +0000 Subject: [PATCH 105/105] fix: revert incorrect change Signed-off-by: davfsa --- lib/queue_manager.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/queue_manager.go b/lib/queue_manager.go index f40a856..ca10fe4 100644 --- a/lib/queue_manager.go +++ b/lib/queue_manager.go @@ -288,7 +288,7 @@ func (m *QueueManager) GetRequestRoutingInfo(req *http.Request, token string) (r routingHash = HashCRC64(token) } else { queueType = Bot - routingHash = HashCRC64(req.Method + path) + routingHash = HashCRC64(path) } return }