Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion site/agents/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@
/>
<link rel="icon" type="image/png" href="../assets/favicon.png" />
<link rel="stylesheet" href="../styles.css?v=20260621-author-byline" />
<script src="../script.js?v=20260621-author-byline" defer></script>
<script src="../script.js?v=20260622-retry-after" defer></script>
<script type="application/ld+json">
{
"@context": "https://schema.org",
Expand Down
2 changes: 1 addition & 1 deletion site/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@
]
}
</script>
<script src="./script.js?v=20260621-author-byline" defer></script>
<script src="./script.js?v=20260622-retry-after" defer></script>
<title>Loop Library: Repeatable AI Agent Workflows | Forward Future</title>
</head>
<body>
Expand Down
2 changes: 1 addition & 1 deletion site/learn/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@
/>
<link rel="icon" type="image/png" href="../assets/favicon.png" />
<link rel="stylesheet" href="../styles.css?v=20260621-author-byline" />
<script src="../script.js?v=20260621-author-byline" defer></script>
<script src="../script.js?v=20260622-retry-after" defer></script>
<script type="application/ld+json">
{
"@context": "https://schema.org",
Expand Down
24 changes: 21 additions & 3 deletions site/script.js
Original file line number Diff line number Diff line change
Expand Up @@ -677,6 +677,20 @@ async function initializeFormProtection() {
}
}

function formatRetryHint(seconds) {
if (!Number.isFinite(seconds) || seconds <= 0) {
return "";
}

if (seconds < 90) {
const rounded = Math.max(1, Math.ceil(seconds));
return ` Try again in about ${rounded} second${rounded === 1 ? "" : "s"}.`;
}

const minutes = Math.max(1, Math.ceil(seconds / 60));
return ` Try again in about ${minutes} minute${minutes === 1 ? "" : "s"}.`;
}

async function postProtectedForm(path, body, fallbackMessage) {
const response = await fetch(`${FORM_API_ORIGIN}${path}`, {
method: "POST",
Expand All @@ -695,9 +709,13 @@ async function postProtectedForm(path, body, fallbackMessage) {
}

if (!response.ok) {
throw new Error(
responseBody.error || fallbackMessage,
);
let message = responseBody.error || fallbackMessage;

if (response.status === 429) {
message += formatRetryHint(Number(response.headers.get("Retry-After")));
}

throw new Error(message);
}
}

Expand Down
1 change: 1 addition & 0 deletions worker/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,7 @@ function getCorsHeaders(origin, env) {

return {
"Access-Control-Allow-Origin": origin,
"Access-Control-Expose-Headers": "Retry-After",
"Cache-Control": "no-store",
Vary: "Origin",
};
Expand Down
4 changes: 4 additions & 0 deletions worker/test/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,10 @@ test("rate limits invalid-token floods before calling Siteverify", async () => {

assert.equal(response.status, 429);
assert.equal(response.headers.get("Retry-After"), "60");
assert.equal(
response.headers.get("Access-Control-Expose-Headers"),
"Retry-After",
);
assert.equal(body.code, "rate_limited");
assert.deepEqual(rateLimitCalls, ["suggestions:203.0.113.10"]);
assert.equal(calls.turnstile.length, 0);
Expand Down
Loading