nandi/frqpublic Fork
⑂ 20b5f50
Commits
⬇ Clone ▾
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

One address for every reader, so ask freeq once

Hitting freeq's rate limit from the web client, and the shape of this relay
is why. Every reader's previews leave from this one process, so freeq sees
a single address for all of them and counts against a single budget -- and
nothing was cached, so one page load asked for a preview per link on
screen, every time it was opened. One reader scrolling a link-heavy backlog
could spend the budget for everybody, and what that looks like on someone
else's screen is cards quietly turning into 429s.

So the answers are kept, in two places for two different reasons. In this
process, where the point is that all those readers share one table and
therefore one request; and in the browser with `max-age`, where the point
is that a reload and a tab restore ask this server nothing at all. An
OpenGraph title is not news: a day here and an hour there are both far
inside how often one changes and far outside how often a backlog is
re-read.

Failures are kept too, for a minute. A host that is down is down for the
next reader as well, and retrying it per page load is how one dead link
becomes the thing spending the budget. A 429 is the exception and is never
stored -- it is not what the link is, it is what freeq thought of us a
moment ago, and keeping it would turn one exhausted budget into a day of
blank cards for a link that was fine.

Bounded at 2048, dropping the oldest third when it fills: this process is
long-lived, and a cache that only grows is a slower leak rather than not
one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-20T18:55:51-07:00 Browse files
20b5f50 parent: ff28020
modified tools/webserve.py +92 -1
@@ -40,6 +40,8 @@ the small one because the page sends no credentials.
4040
4141 import os
4242 import sys
43+import threading
44+import time
4345 import urllib.error
4446 import urllib.request
4547 from functools import partial
@@ -53,6 +55,60 @@ OG = "/api/v1/og"
5355 # have crossed this machine on their way to being turned down.
5456 LIMIT = 12 * 1024 * 1024
5557
58+# What a link turned out to be, kept for a while.
59+#
60+# This is not a speed optimisation, it is a fairness one. Every reader's
61+# previews leave from this one process, so freeq sees one address for all of
62+# them and rate-limits accordingly -- and uncached, a single page load asks
63+# for a preview per link on screen, every time it is opened. One reader
64+# scrolling a link-heavy backlog could spend the budget for everybody, and
65+# what that looks like on the screen is somebody else's previews quietly
66+# turning into 429s.
67+#
68+# An OpenGraph title is not news. A day is far inside how often one changes
69+# and far outside how often a backlog is re-read, which is the pair of
70+# numbers that matters -- and a stale card is a smaller wrong answer than an
71+# absent one.
72+#
73+# Bounded, because this process is long-lived and a cache that only grows is
74+# a slower leak rather than not one. At the cap the oldest third goes: enough
75+# that eviction is rare rather than continuous, and `dict` preserves
76+# insertion order, so "oldest" needs no bookkeeping of its own.
77+#
78+# Failures are cached too, for much less time. A host that is down is down
79+# for the next reader a minute from now, and retrying it per page load is
80+# how one dead link in a backlog becomes the thing spending the budget.
81+OG_TTL = 24 * 60 * 60
82+OG_FAIL_TTL = 60
83+OG_MAX = 2048
84+
85+_og_cache: dict[str, tuple[float, int, bytes, str]] = {}
86+_og_lock = threading.Lock()
87+
88+
89+def og_cached(query):
90+ """The stored answer for this query, or None."""
91+ with _og_lock:
92+ hit = _og_cache.get(query)
93+ if hit is None:
94+ return None
95+ expires, status, body, kind = hit
96+ if expires < time.monotonic():
97+ # Expired rather than absent: dropped here so a URL nobody asks
98+ # for again does not sit in the table until the cap evicts it.
99+ del _og_cache[query]
100+ return None
101+ return status, body, kind
102+
103+
104+def og_remember(query, status, body, kind):
105+ ttl = OG_TTL if status == 200 else OG_FAIL_TTL
106+ with _og_lock:
107+ if len(_og_cache) >= OG_MAX:
108+ for stale in list(_og_cache)[: OG_MAX // 3]:
109+ del _og_cache[stale]
110+ _og_cache[query] = (time.monotonic() + ttl, status, body, kind)
111+
56112
57113 class Handler(SimpleHTTPRequestHandler):
58114 # `http.server` answers HTTP/1.0 and closes the connection after every
@@ -89,7 +145,20 @@ class Handler(SimpleHTTPRequestHandler):
89145 no body. A round trip per file, against a fix that does not arrive.
90146 """
91147 path = self.path.split("?")[0]
92- if path not in (UPLOAD, OG):
148+ if path == OG:
149+ # The one thing here that may be held without revalidating. A
150+ # bundle file is a name whose contents change on every deploy;
151+ # a preview is a URL's OpenGraph tags, which are not about this
152+ # client at all and do not change because we shipped. An hour in
153+ # the browser is a reload, a tab restore and a second visit that
154+ # ask this server nothing -- and this server is where every
155+ # reader's requests converge into the one address freeq counts.
156+ #
157+ # Shorter than the table's own day, deliberately: what a browser
158+ # holds cannot be dropped when it turns out to be wrong, and an
159+ # hour is the length of a sitting rather than of a habit.
160+ self.send_header("Cache-Control", "max-age=3600")
161+ elif path != UPLOAD:
93162 self.send_header("Cache-Control", "no-cache")
94163 SimpleHTTPRequestHandler.end_headers(self)
95164
@@ -108,6 +177,21 @@ class Handler(SimpleHTTPRequestHandler):
108177 self.send_error(400, "url is required")
109178 return
110179
180+ # Answered from the table where it is there, which is most of the
181+ # time: a backlog is re-read far more often than a page's OpenGraph
182+ # tags change. The query string is the key rather than the decoded
183+ # URL -- it is what would be sent upstream, so two requests sharing
184+ # it share an answer by construction.
185+ hit = og_cached(query)
186+ if hit is not None:
187+ status, body, kind = hit
188+ self.send_response(status)
189+ self.send_header("Content-Type", kind)
190+ self.send_header("Content-Length", str(len(body)))
191+ self.end_headers()
192+ self.wfile.write(body)
193+ return
194+
111195 # Nothing of the caller's is forwarded -- no cookies, no
112196 # Authorization, nothing this server was trusted with. The query is
113197 # already encoded by whoever built it and is passed through as it
@@ -128,6 +212,13 @@ class Handler(SimpleHTTPRequestHandler):
128212 except OSError as e:
129213 status, body, kind = 502, str(e).encode(), "text/plain"
130214
215+ # A 429 is never stored. It is not what this link is, it is what
216+ # freeq thought of us a moment ago -- and keeping it would turn one
217+ # exhausted budget into a day of blank cards for a link that was
218+ # fine.
219+ if status != 429:
220+ og_remember(query, status, body, kind)
221+
131222 self.send_response(status)
132223 self.send_header("Content-Type", kind)
133224 self.send_header("Content-Length", str(len(body)))
@@ -40,6 +40,8 @@ the small one because the page sends no credentials.
40 40
41 import os41 import os
42 import sys42 import sys
43+import threading
44+import time
43 import urllib.error45 import urllib.error
44 import urllib.request46 import urllib.request
45 from functools import partial47 from functools import partial
@@ -53,6 +55,60 @@ OG = "/api/v1/og"
53 # have crossed this machine on their way to being turned down.55 # have crossed this machine on their way to being turned down.
54 LIMIT = 12 * 1024 * 102456 LIMIT = 12 * 1024 * 1024
55 57
58+# What a link turned out to be, kept for a while.
59+#
60+# This is not a speed optimisation, it is a fairness one. Every reader's
61+# previews leave from this one process, so freeq sees one address for all of
62+# them and rate-limits accordingly -- and uncached, a single page load asks
63+# for a preview per link on screen, every time it is opened. One reader
64+# scrolling a link-heavy backlog could spend the budget for everybody, and
65+# what that looks like on the screen is somebody else's previews quietly
66+# turning into 429s.
67+#
68+# An OpenGraph title is not news. A day is far inside how often one changes
69+# and far outside how often a backlog is re-read, which is the pair of
70+# numbers that matters -- and a stale card is a smaller wrong answer than an
71+# absent one.
72+#
73+# Bounded, because this process is long-lived and a cache that only grows is
74+# a slower leak rather than not one. At the cap the oldest third goes: enough
75+# that eviction is rare rather than continuous, and `dict` preserves
76+# insertion order, so "oldest" needs no bookkeeping of its own.
77+#
78+# Failures are cached too, for much less time. A host that is down is down
79+# for the next reader a minute from now, and retrying it per page load is
80+# how one dead link in a backlog becomes the thing spending the budget.
81+OG_TTL = 24 * 60 * 60
82+OG_FAIL_TTL = 60
83+OG_MAX = 2048
84+
85+_og_cache: dict[str, tuple[float, int, bytes, str]] = {}
86+_og_lock = threading.Lock()
87+
88+
89+def og_cached(query):
90+ """The stored answer for this query, or None."""
91+ with _og_lock:
92+ hit = _og_cache.get(query)
93+ if hit is None:
94+ return None
95+ expires, status, body, kind = hit
96+ if expires < time.monotonic():
97+ # Expired rather than absent: dropped here so a URL nobody asks
98+ # for again does not sit in the table until the cap evicts it.
99+ del _og_cache[query]
100+ return None
101+ return status, body, kind
102+
103+
104+def og_remember(query, status, body, kind):
105+ ttl = OG_TTL if status == 200 else OG_FAIL_TTL
106+ with _og_lock:
107+ if len(_og_cache) >= OG_MAX:
108+ for stale in list(_og_cache)[: OG_MAX // 3]:
109+ del _og_cache[stale]
110+ _og_cache[query] = (time.monotonic() + ttl, status, body, kind)
111+
56 112
57 class Handler(SimpleHTTPRequestHandler):113 class Handler(SimpleHTTPRequestHandler):
58 # `http.server` answers HTTP/1.0 and closes the connection after every114 # `http.server` answers HTTP/1.0 and closes the connection after every
@@ -89,7 +145,20 @@ class Handler(SimpleHTTPRequestHandler):
89 no body. A round trip per file, against a fix that does not arrive.145 no body. A round trip per file, against a fix that does not arrive.
90 """146 """
91 path = self.path.split("?")[0]147 path = self.path.split("?")[0]
92- if path not in (UPLOAD, OG):148+ if path == OG:
149+ # The one thing here that may be held without revalidating. A
150+ # bundle file is a name whose contents change on every deploy;
151+ # a preview is a URL's OpenGraph tags, which are not about this
152+ # client at all and do not change because we shipped. An hour in
153+ # the browser is a reload, a tab restore and a second visit that
154+ # ask this server nothing -- and this server is where every
155+ # reader's requests converge into the one address freeq counts.
156+ #
157+ # Shorter than the table's own day, deliberately: what a browser
158+ # holds cannot be dropped when it turns out to be wrong, and an
159+ # hour is the length of a sitting rather than of a habit.
160+ self.send_header("Cache-Control", "max-age=3600")
161+ elif path != UPLOAD:
93 self.send_header("Cache-Control", "no-cache")162 self.send_header("Cache-Control", "no-cache")
94 SimpleHTTPRequestHandler.end_headers(self)163 SimpleHTTPRequestHandler.end_headers(self)
95 164
@@ -108,6 +177,21 @@ class Handler(SimpleHTTPRequestHandler):
108 self.send_error(400, "url is required")177 self.send_error(400, "url is required")
109 return178 return
110 179
180+ # Answered from the table where it is there, which is most of the
181+ # time: a backlog is re-read far more often than a page's OpenGraph
182+ # tags change. The query string is the key rather than the decoded
183+ # URL -- it is what would be sent upstream, so two requests sharing
184+ # it share an answer by construction.
185+ hit = og_cached(query)
186+ if hit is not None:
187+ status, body, kind = hit
188+ self.send_response(status)
189+ self.send_header("Content-Type", kind)
190+ self.send_header("Content-Length", str(len(body)))
191+ self.end_headers()
192+ self.wfile.write(body)
193+ return
194+
111 # Nothing of the caller's is forwarded -- no cookies, no195 # Nothing of the caller's is forwarded -- no cookies, no
112 # Authorization, nothing this server was trusted with. The query is196 # Authorization, nothing this server was trusted with. The query is
113 # already encoded by whoever built it and is passed through as it197 # already encoded by whoever built it and is passed through as it
@@ -128,6 +212,13 @@ class Handler(SimpleHTTPRequestHandler):
128 except OSError as e:212 except OSError as e:
129 status, body, kind = 502, str(e).encode(), "text/plain"213 status, body, kind = 502, str(e).encode(), "text/plain"
130 214
215+ # A 429 is never stored. It is not what this link is, it is what
216+ # freeq thought of us a moment ago -- and keeping it would turn one
217+ # exhausted budget into a day of blank cards for a link that was
218+ # fine.
219+ if status != 429:
220+ og_remember(query, status, body, kind)
221+
131 self.send_response(status)222 self.send_response(status)
132 self.send_header("Content-Type", kind)223 self.send_header("Content-Type", kind)
133 self.send_header("Content-Length", str(len(body)))224 self.send_header("Content-Length", str(len(body)))