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:
1 parent
79eb34840f
commit
54ca651f37
12 files changed
+396
-7
No files matched your search
@@ -507,6 +507,10 @@ tail -1 "$TMP" # hand this line's key to its bearer, then
|
||||
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
|
||||
does not start; with an empty file nobody passes (`GATE_NO_KEYS` in the
|
||||
vav-pipeline log).
|
||||
|
||||
@@ -46,6 +46,12 @@ metadata:
|
||||
service.beta.kubernetes.io/exoscale-loadbalancer-name: "shorashim"
|
||||
spec:
|
||||
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:
|
||||
app.kubernetes.io/name: shorashim
|
||||
ports:
|
||||
|
||||
@@ -47,6 +47,9 @@ Rate limit: 10 requests per minute per IP
|
||||
100 requests per hour per IP
|
||||
No identification stored. IP is used for rate-limiting only,
|
||||
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.
|
||||
@@ -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
|
||||
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
|
||||
|
||||
@@ -174,6 +187,7 @@ Errors:
|
||||
401 — Invalid or missing API key
|
||||
403 — A key for an outer ring at an inner gate
|
||||
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,
|
||||
TALMUDIC_UNAVAILABLE. Body says which; resubmit: true.
|
||||
|
||||
|
||||
@@ -20,7 +20,14 @@ config :vav_pipeline,
|
||||
korban_socket_path: "/tmp/shorashim/korban.sock",
|
||||
# Courtyard / chatzer keys (BigdeiKodesh.Gate). nil → the gate is shut.
|
||||
# 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)
|
||||
# Architecture §3.7: Append-only. INSERT allowed. Never UPDATE. Never DELETE.
|
||||
|
||||
@@ -7,6 +7,13 @@ config :vav_pipeline,
|
||||
l1_mount_point: "../l1",
|
||||
manna_provision_file: "test/fixtures/manna/today.json",
|
||||
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),
|
||||
http_port: 0
|
||||
|
||||
|
||||
@@ -47,7 +47,8 @@ defmodule VavPipeline.Application do
|
||||
{VavPipeline.VoiceMonitor, []},
|
||||
{VavPipeline.MachtzitHaShekel, []},
|
||||
# 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_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
|
||||
["Bearer " <> key] when key != "" -> {:ok, String.trim(key)}
|
||||
_ -> {: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
|
||||
|
||||
alias VavPipeline.BigdeiKodesh.Gate
|
||||
alias VavPipeline.BigdeiKodesh.{Gate, Mishmar}
|
||||
|
||||
# Form 3 architectural enforcement (vehaya §3.2). NoTrackingLogger
|
||||
# replaces Plug.Logger; sanitized paths on /peah/* honor the
|
||||
@@ -63,6 +63,9 @@ defmodule VavPipeline.BigdeiKodesh.Router do
|
||||
end
|
||||
|
||||
post "/peah/ask" do
|
||||
# Tamid §1.1: 10 a minute, 100 an hour, per IP; the IP is used for
|
||||
# this and nothing else (Mishmar keeps a salted hash, not the IP).
|
||||
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)
|
||||
@@ -74,6 +77,7 @@ defmodule VavPipeline.BigdeiKodesh.Router do
|
||||
resubmit: true
|
||||
})
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
# ============================================================
|
||||
@@ -129,6 +133,32 @@ defmodule VavPipeline.BigdeiKodesh.Router do
|
||||
})
|
||||
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 ===
|
||||
#
|
||||
# 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
|
||||
case Gate.admit(conn, tier) do
|
||||
{: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} ->
|
||||
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("UNAUTHORIZED"), do: "authentication_required"
|
||||
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("LO_TOV"), do: "specification_mismatch"
|
||||
defp error_type("CHATAT" <> _), do: "correction_needed"
|
||||
|
||||
@@ -175,7 +175,7 @@ defmodule VavPipeline.Doorpost do
|
||||
# changes; the update is a deliberate operator commit. A bare
|
||||
# hash-update commit (without a corresponding spec change in the
|
||||
# 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."
|
||||
@spec files() :: [String.t()]
|
||||
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
Reference in new issue
Block a user