Tablet 46: the watch — rate limits at every gate, per IP at the pe'ah, per key inside

Tamid §1.1 wrote the pe'ah's limit (10/min, 100/h per IP); nothing
enforced it, on any tier, and every admitted request spends manna.

VavPipeline.BigdeiKodesh.Mishmar: fixed minute and hour windows per
subject in ETS, the subject stored only as sha256(boot salt, tier,
subject) — no IP and no key is ever in memory as itself, the sweep
drops closed windows, nothing survives a restart (the Tamid's "not
logged beyond the current request", kept in memory too). A refused
knock is not counted. 429 RATE_LIMITED with Retry-After and the manna
explanation (Shemot 16:18). Pe'ah per peer address; courtyard and
chatzer per key, after the gate (a wrong key is 401 and never counted).
Numbers: pe'ah 10/100 (Tamid), courtyard 20/200 and chatzer 5/60
(from Construction §2.3; config :rate_limits).

Service shorashim-external: externalTrafficPolicy Local. With Cluster
the pod saw every caller as the node, 91.92.153.113 (observed via
/proc/net/tcp) — a per-IP limit would have been one bucket for the
world.

Spec: tamid §1.1 (watch, 429 rows, the Service note), KOHEN.md.
Doorpost 42b38626… → e4e3d56d…. tablet46_mishmar_test; Elixir 533/0
(L3 on and off).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Scott KarpandClaude Fable 5.1 committed 2026-10-06 16:08:57 +00:00
1 parent 79eb34840f
commit 54ca651f37
12 files changed
+406 -17

No files matched your search

+4
View File
@@ -507,6 +507,10 @@ tail -1 "$TMP" # hand this line's key to its bearer, then
rm -f "$TMP" rm -f "$TMP"
``` ```
Each key may knock 20 times a minute / 200 an hour at the courtyard
and 5 / 60 at the chatzer; the pe'ah is 10 / 100 per IP. Over that the
caller gets 429 with `Retry-After` (Tamid §1.1).
To revoke: remove the line the same way. Without the Secret the pod To revoke: remove the line the same way. Without the Secret the pod
does not start; with an empty file nobody passes (`GATE_NO_KEYS` in the does not start; with an empty file nobody passes (`GATE_NO_KEYS` in the
vav-pipeline log). vav-pipeline log).
+6
View File
@@ -46,6 +46,12 @@ metadata:
service.beta.kubernetes.io/exoscale-loadbalancer-name: "shorashim" service.beta.kubernetes.io/exoscale-loadbalancer-name: "shorashim"
spec: spec:
type: LoadBalancer type: LoadBalancer
# Local: kube-proxy does not SNAT, so the pod sees the caller's own
# address and the pe'ah's per-IP watch (Tamid §1.1) counts callers,
# not the node. With Cluster (the default) every caller arrived as the
# node IP 91.92.153.113 — observed 2026-10-06, tablet 46. One node,
# one replica: Local loses nothing.
externalTrafficPolicy: Local
selector: selector:
app.kubernetes.io/name: shorashim app.kubernetes.io/name: shorashim
ports: ports:
+14
View File
@@ -47,6 +47,9 @@ Rate limit: 10 requests per minute per IP
100 requests per hour per IP 100 requests per hour per IP
No identification stored. IP is used for rate-limiting only, No identification stored. IP is used for rate-limiting only,
not logged beyond the current request. not logged beyond the current request.
429 carries Retry-After and the manna explanation.
(The Service must deliver the caller's address to the pod —
externalTrafficPolicy: Local — or every caller is one IP.)
``` ```
**Design requirements:** No cookies. No session state. No analytics. No tracking pixels. No JavaScript fingerprinting. The pe'ah endpoint collects nothing. The stranger is safe to approach. **Design requirements:** No cookies. No session state. No analytics. No tracking pixels. No JavaScript fingerprinting. The pe'ah endpoint collects nothing. The stranger is safe to approach.
@@ -62,6 +65,16 @@ file or no keys: nobody passes, and the log says `GATE_NO_KEYS`. The
caller is never told why beyond "missing or invalid API key". Issued by caller is never told why beyond "missing or invalid API key". Issued by
the kohen (KOHEN.md "Issuing a key"); the key never appears in a log. the kohen (KOHEN.md "Issuing a key"); the key never appears in a log.
**The watch.** Past the gate, each key may knock 20 times a minute and
200 times an hour at the courtyard (Construction §2.3: 200 korbanot an
hour is the system's peak — one key may not be the whole peak), 5 a
minute and 60 an hour at the chatzer (five panels at once). The pe'ah's
10/100 per IP is above. Counted per key, not per IP; a refused key is
never counted. The counters are salted hashes in memory, swept as the
windows close — no list of who called exists. 429 carries `Retry-After`
and the manna explanation. The numbers are configuration
(`config :vav_pipeline, :rate_limits`); the pe'ah's are this document's.
``` ```
Endpoint: POST /courtyard/offering Endpoint: POST /courtyard/offering
@@ -174,6 +187,7 @@ Errors:
401 — Invalid or missing API key 401 — Invalid or missing API key
403 — A key for an outer ring at an inner gate 403 — A key for an outer ring at an inner gate
422 — Missing or empty output, output over 65,536 bytes, unknown model 422 — Missing or empty output, output over 65,536 bytes, unknown model
429 — Rate limit exceeded (20/min, 200/h per key). Retry-After; manna explanation.
503 — MANNA_DEPLETED (provision exhausted), KORBAN_UNAVAILABLE, 503 — MANNA_DEPLETED (provision exhausted), KORBAN_UNAVAILABLE,
TALMUDIC_UNAVAILABLE. Body says which; resubmit: true. TALMUDIC_UNAVAILABLE. Body says which; resubmit: true.
+8 -1
View File
@@ -20,7 +20,14 @@ config :vav_pipeline,
korban_socket_path: "/tmp/shorashim/korban.sock", korban_socket_path: "/tmp/shorashim/korban.sock",
# Courtyard / chatzer keys (BigdeiKodesh.Gate). nil → the gate is shut. # Courtyard / chatzer keys (BigdeiKodesh.Gate). nil → the gate is shut.
# Construction §4.6: a file, never an environment variable. # Construction §4.6: a file, never an environment variable.
api_keys_file: nil api_keys_file: nil,
# The watch (BigdeiKodesh.Mishmar). pe'ah is Tamid §1.1 verbatim; the
# other two derive from Construction §2.3 and may be tuned here.
rate_limits: [
peah: [minute: 10, hour: 100],
courtyard: [minute: 20, hour: 200],
chatzer: [minute: 5, hour: 60]
]
# L3 — Oral Tradition (PostgreSQL) # L3 — Oral Tradition (PostgreSQL)
# Architecture §3.7: Append-only. INSERT allowed. Never UPDATE. Never DELETE. # Architecture §3.7: Append-only. INSERT allowed. Never UPDATE. Never DELETE.
+7
View File
@@ -7,6 +7,13 @@ config :vav_pipeline,
l1_mount_point: "../l1", l1_mount_point: "../l1",
manna_provision_file: "test/fixtures/manna/today.json", manna_provision_file: "test/fixtures/manna/today.json",
api_keys_file: "test/fixtures/api_keys", api_keys_file: "test/fixtures/api_keys",
# Wide open for the suite (Day 6/7 tests knock many times a second);
# tablet46_mishmar_test narrows them and resets.
rate_limits: [
peah: [minute: 10_000, hour: 100_000],
courtyard: [minute: 10_000, hour: 100_000],
chatzer: [minute: 10_000, hour: 100_000]
],
cycle_boundary_check_interval_ms: :timer.hours(24), cycle_boundary_check_interval_ms: :timer.hours(24),
http_port: 0 http_port: 0
+2 -1
View File
@@ -47,7 +47,8 @@ defmodule VavPipeline.Application do
{VavPipeline.VoiceMonitor, []}, {VavPipeline.VoiceMonitor, []},
{VavPipeline.MachtzitHaShekel, []}, {VavPipeline.MachtzitHaShekel, []},
# Tablet 30 — the index that lets a question name its own verse # Tablet 30 — the index that lets a question name its own verse
{VavPipeline.L1Index, []} {VavPipeline.L1Index, []},
{VavPipeline.BigdeiKodesh.Mishmar, []}
] ]
|> maybe_add_http_server() |> maybe_add_http_server()
|> maybe_add_repo() |> maybe_add_repo()
@@ -85,7 +85,9 @@ defmodule VavPipeline.BigdeiKodesh.Gate do
# ------------------------------------------------------------ # ------------------------------------------------------------
defp bearer(conn) do @doc "The bearer key on the request, if the header has one."
@spec bearer(Plug.Conn.t()) :: {:ok, String.t()} | {:error, :no_header}
def bearer(conn) do
case Plug.Conn.get_req_header(conn, "authorization") do case Plug.Conn.get_req_header(conn, "authorization") do
["Bearer " <> key] when key != "" -> {:ok, String.trim(key)} ["Bearer " <> key] when key != "" -> {:ok, String.trim(key)}
_ -> {:error, :no_header} _ -> {:error, :no_header}
@@ -0,0 +1,180 @@
defmodule VavPipeline.BigdeiKodesh.Mishmar do
@moduledoc """
The watch at each gate — how often one hand may knock.
Bamidbar 18:4 — *v'shamru et mishmeret ohel mo'ed*: the Levites keep
the watch of the tent. The watch does not decide who may enter (that
is the Gate); it decides how often. The day's manna is one portion for
everyone who comes; without a watch one caller eats it all before noon.
Tamid §1.1 fixes the pe'ah: 10 requests a minute and 100 an hour per
IP, and "no identification stored — IP is used for rate-limiting only,
not logged beyond the current request." For the courtyard and the
chatzer the Tamid says only that 429 exists and carries the manna
explanation; the numbers here come from Construction §2.3 (peak
korbanot per hour 200; five Sanhedrin panels at once) and are
configuration, not law — `config :vav_pipeline, :rate_limits`.
**What is kept.** Two fixed windows per subject — the current minute
and the current hour — as counters in an ETS table. The subject is
not the IP and not the key: it is `sha256(salt <> subject)` with a
salt drawn at boot and never written anywhere. The table cannot be
read back into a list of who called; it cannot survive a restart;
the sweep drops a window a few minutes after it closes. This is the
Tamid's "not logged beyond the current request", kept in memory too.
**What is answered.** `{:ok, remaining_this_minute}` or
`{:error, window, retry_after_seconds}`. The router turns the latter
into 429 with `Retry-After`.
"""
use GenServer
require Logger
@table __MODULE__
@sweep_ms :timer.minutes(1)
@type tier :: :peah | :courtyard | :chatzer
@type subject :: binary()
@default_limits [
# Tamid §1.1, verbatim.
peah: [minute: 10, hour: 100],
# Construction §2.3: 200 korbanot an hour at peak — one key may not
# be the whole peak. 20 a minute lets a client work; 200 an hour is
# the system's own ceiling.
courtyard: [minute: 20, hour: 200],
# Construction §2.3: five panels at once, deliberation measured in
# minutes. A key convening more than five cases a minute is not a
# court, it is a loop.
chatzer: [minute: 5, hour: 60]
]
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: Keyword.get(opts, :name, __MODULE__))
end
@doc "The configured limits for a tier, `[minute: n, hour: n]`."
@spec limits(tier()) :: keyword()
def limits(tier) do
configured = Application.get_env(:vav_pipeline, :rate_limits, [])
Keyword.get(configured, tier) || Keyword.fetch!(@default_limits, tier)
end
@doc """
Count one knock by `subject` at `tier`.
Returns `{:ok, remaining}` (knocks left this minute) or
`{:error, :minute | :hour, retry_after_seconds}`. A refused knock is
not counted: a caller who keeps knocking is not punished further, but
is not let in either.
When the watch is not running (tests without the supervisor) every
knock is allowed — and logged once as MISHMAR_ABSENT, because that is
not a state production may be in.
"""
@spec knock(tier(), subject(), integer()) ::
{:ok, non_neg_integer()} | {:error, :minute | :hour, pos_integer()}
def knock(tier, subject, now \\ System.os_time(:second)) when is_binary(subject) do
case :ets.whereis(@table) do
:undefined ->
absent()
{:ok, 0}
_ ->
tier_limits = limits(tier)
per_minute = Keyword.fetch!(tier_limits, :minute)
per_hour = Keyword.fetch!(tier_limits, :hour)
id = hashed(tier, subject)
minute = div(now, 60)
hour = div(now, 3600)
minute_count = :ets.lookup_element(@table, {id, :minute, minute}, 2, 0)
hour_count = :ets.lookup_element(@table, {id, :hour, hour}, 2, 0)
cond do
hour_count >= per_hour ->
{:error, :hour, (hour + 1) * 3600 - now}
minute_count >= per_minute ->
{:error, :minute, (minute + 1) * 60 - now}
true ->
:ets.update_counter(@table, {id, :minute, minute}, {2, 1}, {{id, :minute, minute}, 0})
:ets.update_counter(@table, {id, :hour, hour}, {2, 1}, {{id, :hour, hour}, 0})
{:ok, per_minute - minute_count - 1}
end
end
end
@doc "The subject for a request at the pe'ah: its peer address, as text."
@spec peer(Plug.Conn.t()) :: subject()
def peer(%Plug.Conn{remote_ip: ip}) do
case :inet.ntoa(ip) do
{:error, _} -> inspect(ip)
chars -> List.to_string(chars)
end
end
@doc "Forget every window. For tests."
def reset(server \\ __MODULE__), do: GenServer.call(server, :reset)
@doc "Drop the windows that have closed. Runs on its own every minute; callable for tests."
def sweep(server \\ __MODULE__, now \\ System.os_time(:second)), do: GenServer.call(server, {:sweep, now})
# ------------------------------------------------------------
@impl true
def init(_opts) do
:ets.new(@table, [:named_table, :public, :set, write_concurrency: true, read_concurrency: true])
# The salt lives only in this process's state and in the (private)
# persistent key below. It is never logged, never written, and is
# different after every restart.
salt = :crypto.strong_rand_bytes(32)
:persistent_term.put({__MODULE__, :salt}, salt)
Process.send_after(self(), :sweep, @sweep_ms)
{:ok, %{}}
end
@impl true
def handle_call(:reset, _from, state) do
:ets.delete_all_objects(@table)
{:reply, :ok, state}
end
def handle_call({:sweep, now}, _from, state) do
do_sweep(now)
{:reply, :ok, state}
end
@impl true
def handle_info(:sweep, state) do
do_sweep(System.os_time(:second))
Process.send_after(self(), :sweep, @sweep_ms)
{:noreply, state}
end
# A minute window is dead once the minute after it has passed; an
# hour window once the hour after it has. Nothing older is kept.
defp do_sweep(now) do
minute = div(now, 60)
hour = div(now, 3600)
:ets.select_delete(@table, [
{{{:_, :minute, :"$1"}, :_}, [{:<, :"$1", minute - 1}], [true]},
{{{:_, :hour, :"$1"}, :_}, [{:<, :"$1", hour - 1}], [true]}
])
end
defp hashed(tier, subject) do
salt = :persistent_term.get({__MODULE__, :salt})
:crypto.hash(:sha256, [salt, Atom.to_string(tier), 0, subject])
end
defp absent do
unless :persistent_term.get({__MODULE__, :absent_logged}, false) do
:persistent_term.put({__MODULE__, :absent_logged}, true)
Logger.warning("MISHMAR_ABSENT: the watch is not running — no rate limit is applied")
end
end
end
@@ -28,7 +28,7 @@ defmodule VavPipeline.BigdeiKodesh.Router do
require Logger require Logger
alias VavPipeline.BigdeiKodesh.Gate alias VavPipeline.BigdeiKodesh.{Gate, Mishmar}
# Form 3 architectural enforcement (vehaya §3.2). NoTrackingLogger # Form 3 architectural enforcement (vehaya §3.2). NoTrackingLogger
# replaces Plug.Logger; sanitized paths on /peah/* honor the # replaces Plug.Logger; sanitized paths on /peah/* honor the
@@ -63,17 +63,21 @@ defmodule VavPipeline.BigdeiKodesh.Router do
end end
post "/peah/ask" do post "/peah/ask" do
case conn.body_params do # Tamid §1.1: 10 a minute, 100 an hour, per IP; the IP is used for
%{"question" => question} when is_binary(question) and question != "" -> # this and nothing else (Mishmar keeps a salted hash, not the IP).
process_peah(conn, question) under_the_watch(conn, :peah, Mishmar.peer(conn), fn conn ->
case conn.body_params do
%{"question" => question} when is_binary(question) and question != "" ->
process_peah(conn, question)
_ -> _ ->
send_error(conn, 422, "KIYOR_VALIDATION_FAILED", %{ send_error(conn, 422, "KIYOR_VALIDATION_FAILED", %{
issue: "Missing or empty 'question' field", issue: "Missing or empty 'question' field",
correction: "Submit JSON with a 'question' string", correction: "Submit JSON with a 'question' string",
resubmit: true resubmit: true
}) })
end end
end)
end end
# ============================================================ # ============================================================
@@ -129,6 +133,32 @@ defmodule VavPipeline.BigdeiKodesh.Router do
}) })
end end
# The watch (Mishmar): how often one subject may knock at a tier.
# 429 carries the manna explanation Tamid §1.1 asks for, and Retry-After.
defp under_the_watch(conn, tier, subject, serve) do
case Mishmar.knock(tier, subject) do
{:ok, _remaining} ->
serve.(conn)
{:error, window, retry_after} ->
limits = Mishmar.limits(tier)
conn
|> put_resp_header("retry-after", Integer.to_string(retry_after))
|> send_error(429, "RATE_LIMITED", %{
issue:
"Too many requests this #{window}: the #{tier} allows " <>
"#{limits[:minute]} a minute and #{limits[:hour]} an hour per caller",
correction: "Wait #{retry_after}s and resubmit",
manna:
"The day's provision is one portion for everyone who comes (Shemot 16:18 — " <>
"the one who gathered much had nothing over). The watch keeps one caller " <>
"from gathering the whole omer.",
resubmit: true
})
end
end
# === COURTYARD — Authenticated === # === COURTYARD — Authenticated ===
# #
# The key is checked against the keys file (VavPipeline.BigdeiKodesh.Gate), # The key is checked against the keys file (VavPipeline.BigdeiKodesh.Gate),
@@ -136,7 +166,10 @@ defmodule VavPipeline.BigdeiKodesh.Router do
defp through_the_gate(conn, tier, serve) do defp through_the_gate(conn, tier, serve) do
case Gate.admit(conn, tier) do case Gate.admit(conn, tier) do
{:ok, _granted} -> {:ok, _granted} ->
serve.(conn) # Past the gate, the key is the hand that knocks: the watch
# counts per key, not per IP. A refused key never reaches it.
{:ok, key} = Gate.bearer(conn)
under_the_watch(conn, tier, key, serve)
{:error, :tier_too_low} -> {:error, :tier_too_low} ->
send_error(conn, 403, "FORBIDDEN", %{ send_error(conn, 403, "FORBIDDEN", %{
@@ -951,6 +984,7 @@ defmodule VavPipeline.BigdeiKodesh.Router do
defp error_type("HALT" <> _), do: "cardinal_sin_detected" defp error_type("HALT" <> _), do: "cardinal_sin_detected"
defp error_type("UNAUTHORIZED"), do: "authentication_required" defp error_type("UNAUTHORIZED"), do: "authentication_required"
defp error_type("FORBIDDEN"), do: "insufficient_tier" defp error_type("FORBIDDEN"), do: "insufficient_tier"
defp error_type("RATE_LIMITED"), do: "too_many_requests"
defp error_type("TEIKU" <> _), do: "unresolvable" defp error_type("TEIKU" <> _), do: "unresolvable"
defp error_type("LO_TOV"), do: "specification_mismatch" defp error_type("LO_TOV"), do: "specification_mismatch"
defp error_type("CHATAT" <> _), do: "correction_needed" defp error_type("CHATAT" <> _), do: "correction_needed"
+1 -1
View File
@@ -175,7 +175,7 @@ defmodule VavPipeline.Doorpost do
# changes; the update is a deliberate operator commit. A bare # changes; the update is a deliberate operator commit. A bare
# hash-update commit (without a corresponding spec change in the # hash-update commit (without a corresponding spec change in the
# same commit) is itself a drift signal. # same commit) is itself a drift signal.
@expected_hash "42b38626c6e2bd3e6fe10ed7f4cb34ee8dae118205379b7d822f278fe7eaa110" @expected_hash "e4e3d56de7422280b5562c4a7ebce02fe05225a938bbce26d6e52f26b6b240eb"
@doc "Return the list of files that constitute the category-grade specification." @doc "Return the list of files that constitute the category-grade specification."
@spec files() :: [String.t()] @spec files() :: [String.t()]
-1
View File
@@ -1 +0,0 @@
{"consumed":{"api_calls_external":0,"compute_units":3,"memory_mb":0,"sanhedrin_panels_max":0,"storage_delta_mb":0},"cycle_date":"2026-10-06","written_at":"2026-10-06T15:15:24.042079Z"}
+135
View File
@@ -0,0 +1,135 @@
defmodule VavPipeline.Tablet46MishmarTest do
@moduledoc """
Tablet 46 (2026-10-06) — no gate had a watch.
Tamid §1.1 writes the pe'ah's limit in the spec; nothing enforced it,
on any tier. Every admitted request spends manna; one caller could
gather the whole omer.
"""
use ExUnit.Case, async: false
import Plug.Test
import Plug.Conn
alias VavPipeline.BigdeiKodesh.{Mishmar, Router}
@opts Router.init([])
setup do
original = Application.get_env(:vav_pipeline, :rate_limits)
Application.put_env(:vav_pipeline, :rate_limits,
peah: [minute: 3, hour: 5],
courtyard: [minute: 2, hour: 100],
chatzer: [minute: 10_000, hour: 100_000]
)
Mishmar.reset()
on_exit(fn ->
Application.put_env(:vav_pipeline, :rate_limits, original)
Mishmar.reset()
end)
:ok
end
describe "Mishmar.knock/3" do
test "counts the minute, then the hour, per subject; a refusal is not counted" do
t = 1_000_000 * 60
assert {:ok, 2} = Mishmar.knock(:peah, "a", t)
assert {:ok, 1} = Mishmar.knock(:peah, "a", t + 1)
assert {:ok, 0} = Mishmar.knock(:peah, "a", t + 2)
assert {:error, :minute, 57} = Mishmar.knock(:peah, "a", t + 3)
assert {:error, :minute, 57} = Mishmar.knock(:peah, "a", t + 3)
# Another subject is another count.
assert {:ok, 2} = Mishmar.knock(:peah, "b", t + 3)
# The next minute opens — but the hour (5) is nearly spent: 3 so far.
assert {:ok, 2} = Mishmar.knock(:peah, "a", t + 60)
assert {:ok, 1} = Mishmar.knock(:peah, "a", t + 61)
assert {:error, :hour, retry} = Mishmar.knock(:peah, "a", t + 62)
assert retry == 3600 - rem(t + 62, 3600)
# The next hour opens.
assert {:ok, 2} = Mishmar.knock(:peah, "a", t + 3600)
end
test "tiers are counted apart" do
t = 2_000_000 * 60
assert {:ok, 1} = Mishmar.knock(:courtyard, "k", t)
assert {:ok, 0} = Mishmar.knock(:courtyard, "k", t)
assert {:error, :minute, _} = Mishmar.knock(:courtyard, "k", t)
assert {:ok, 2} = Mishmar.knock(:peah, "k", t)
end
test "the sweep drops closed windows and nothing else" do
t = 3_000_000 * 60
assert {:ok, _} = Mishmar.knock(:peah, "old", t)
assert {:ok, _} = Mishmar.knock(:peah, "new", t + 7200)
:ok = Mishmar.sweep(Mishmar, t + 7200)
# "old" has been forgotten entirely; "new" is still counted.
assert :ets.info(Mishmar, :size) == 2
assert {:ok, 1} = Mishmar.knock(:peah, "new", t + 7200)
end
test "the table holds hashes, never the subject" do
:ok = Mishmar.reset()
{:ok, _} = Mishmar.knock(:peah, "203.0.113.7")
for {{id, _, _}, _} <- :ets.tab2list(Mishmar) do
assert is_binary(id) and byte_size(id) == 32
refute id =~ "203.0.113"
end
end
end
describe "at the router" do
test "the pe'ah: the fourth knock from one address in a minute is 429 with Retry-After and the manna explanation" do
statuses = for _ <- 1..4, do: ask({203, 0, 113, 9}).status
assert Enum.take(statuses, 3) |> Enum.all?(&(&1 != 429))
conn = ask({203, 0, 113, 9})
assert conn.status == 429
[retry] = get_resp_header(conn, "retry-after")
assert String.to_integer(retry) in 1..60
body = Jason.decode!(conn.resp_body)
assert body["error"]["code"] == "RATE_LIMITED"
assert body["error"]["guidance"]["manna"] =~ "Shemot 16:18"
# Another address is not held back by this one.
refute ask({203, 0, 113, 10}).status == 429
end
test "the courtyard: per key, and only past the gate" do
# A wrong key knocks all it likes and is never counted — 401, not 429.
for _ <- 1..5, do: assert(bedikah("Bearer wrong").status == 401)
assert bedikah("Bearer test-token").status != 429
assert bedikah("Bearer test-token").status != 429
conn = bedikah("Bearer test-token")
assert conn.status == 429
assert Jason.decode!(conn.resp_body)["error"]["guidance"]["issue"] =~ "courtyard allows 2 a minute"
# The chatzer key is its own hand, even at the same door.
refute bedikah("Bearer chatzer-test-token").status == 429
end
end
defp ask(ip) do
%{conn(:post, "/peah/ask", Jason.encode!(%{question: "x"})) | remote_ip: ip}
|> put_req_header("content-type", "application/json")
|> Router.call(@opts)
end
defp bedikah(auth) do
conn(:post, "/courtyard/bedikah", Jason.encode!(%{output: "", model: "gpt"}))
|> put_req_header("content-type", "application/json")
|> put_req_header("authorization", auth)
|> Router.call(@opts)
end
end