api-surface #1

Merged
luna merged 51 commits from api-surface into mistress 2026-07-05 14:43:13 +00:00
2 changed files with 35 additions and 0 deletions
Showing only changes of commit 09c9f2b65a - Show all commits

feat(ratelimit): collapse invite codes in route keys; cover new route shapes

Luna 2026-07-05 05:35:24 -03:00

View file

@ -108,6 +108,9 @@ defmodule Dexcord.Api.Ratelimit do
template = "/" <> Enum.join(templatize(segments), "/")
base = verb <> " " <> template
# As of the 2026-07 docs the separate message-delete bucket is no longer
# documented, but this suffix only splits buckets more finely (safe) - kept
# to avoid 429s should the server-side behavior still exist.
if method == :delete and String.ends_with?(template, "/messages/:id") do
base <> " [message-delete]"
else
@ -138,6 +141,11 @@ defmodule Dexcord.Api.Ratelimit do
defp templatize(["reactions", _emoji | rest]),
do: ["reactions", ":id" | templatize(rest)]
# invite codes are non-numeric, so they'd otherwise stay literal - collapse
# them to keep bucket keys bounded and keep invite codes out of ETS.
defp templatize(["invites", _code | rest]),
do: ["invites", ":code" | templatize(rest)]
defp templatize([seg | rest]), do: [templatize_one(seg) | templatize(rest)]
defp templatize([]), do: []

View file

@ -93,6 +93,33 @@ defmodule Dexcord.Api.RatelimitTest do
assert Ratelimit.route_key(:get, "/channels/1/messages?limit=50") ==
"GET /channels/1/messages"
end
test "invite codes collapse to :code and never leak literally" do
key = Ratelimit.route_key(:delete, "/invites/aBcD3fG")
assert key == "DELETE /invites/:code"
refute key =~ "aBcD3fG"
# query strings are stripped before the code collapses.
assert Ratelimit.route_key(:get, "/invites/xyz?with_counts=true") ==
"GET /invites/:code"
end
test "new route shapes collapse via the digits fallback and keep majors" do
# sticker-pack id is a minor param.
assert Ratelimit.route_key(:get, "/sticker-packs/123456789") ==
"GET /sticker-packs/:id"
# application emojis: application id and emoji id are both minor.
assert Ratelimit.route_key(:post, "/applications/123/emojis") ==
"POST /applications/:id/emojis"
assert Ratelimit.route_key(:delete, "/applications/123/emojis/456") ==
"DELETE /applications/:id/emojis/:id"
# guild id is a major param and stays literal even on emoji routes.
assert Ratelimit.route_key(:get, "/guilds/1/emojis/2") ==
"GET /guilds/1/emojis/:id"
end
end
describe "bucket + global math (injected clock)" do