IndexNow: try Bing's and Yandex's endpoints when api.indexnow.org rate-limits the Worker (#259)
* IndexNow: try Bing's and Yandex's endpoints when api.indexnow.org rate-limits the Worker
- CI / Typecheck and bundlesuccess
- CI / Unit tests (1/2)success
- CI / Unit tests (2/2)success
- Deploy / Deploy to Cloudflaresuccess
- Deploy / Main's current tipsuccess
- End-to-end / End-to-end (agent reviews, tasks, Today)success
- End-to-end / End-to-end (git, PRs, Actions)success
- End-to-end / End-to-end (large pushes)success
- End-to-end / End-to-end (passkeys, registry, email)success
- End-to-end / End-to-end (real histories)success
- End-to-end / End-to-end (runner Docker)success
- End-to-end / End-to-end (security)success
- Live test / Containers, registry, sandboxes, webhooks, schedules, LFS and pull requestssuccess
6 files changed+82−33
Files (6)
modifiedapps/diffinery/docs/admin/site-administration.md+2−2View file
| @@ -419,11 +419,11 @@ | ||
| 419 | 419 | - **Every hour**, public repositories pushed since they were last submitted (their page, `/<owner>/<repo>`, as the sitemap lists it). |
| 420 | 420 | - **Never** a private repository, a repository whose owner is suspended, or any page behind sign-in (settings, notifications, the API, sign-in pages). A repository is only submitted in the same check that found it public, so one made private in the meantime isn't sent, and it is forgotten. Removed site and docs pages are submitted once more, so search engines learn they are gone. |
| 421 | 421 | |
| 422 | -How it works: the Worker's five-minute cron notices a new deploy, hashes each page's content and compares the hashes with the ones it last submitted, then sends only the differences in one request to `api.indexnow.org` (up to 10,000 URLs; more wait for the next run). A run on an older version during a deploy's rollout is ignored. Submitting happens after the deploy, in the background, so it can't fail or slow one. If IndexNow answers `429 Too Many Requests`, the pages stay queued for an hour; after another refusal (`400`, `403`, `422`) for 6 hours; if it can't be reached or fails (`5xx`, a timeout), for 15 minutes. IndexNow's `200 OK` and `202 Accepted` (key validation pending) count as submitted. | |
| 422 | +How it works: the Worker's five-minute cron notices a new deploy, hashes each page's content and compares the hashes with the ones it last submitted, then sends only the differences in one request (up to 10,000 URLs; more wait for the next run) to `api.indexnow.org`. Engines share what any of them receives, so when `api.indexnow.org` answers `429` or `5xx` or can't be reached, the same request goes to Bing's own endpoint (`www.bing.com/indexnow`), then Yandex's (`yandex.com/indexnow`): IndexNow limits requests per IP address, and a Worker sends from addresses it shares with other Workers. A run on an older version during a deploy's rollout is ignored. Submitting happens after the deploy, in the background, so it can't fail or slow one. If every endpoint answers `429 Too Many Requests`, the pages stay queued for an hour; after another refusal (`400`, `403`, `422`) for 6 hours; if it can't be reached or fails (`5xx`, a timeout), for 15 minutes. IndexNow's `200 OK` and `202 Accepted` (key validation pending) count as submitted. | |
| 423 | 423 | |
| 424 | 424 | The key that proves submissions come from this site is served at the site root, `/<key>.txt`. It is 32 hex characters derived from the instance's `MASTER_KEY` and `PUBLIC_URL`, so each instance has its own and nothing needs configuring. [Rotating MASTER_KEY](/docs/self-hosting/configuration#rotating-master_key) gives the instance a new key, which the next submission uses. |
| 425 | 425 | |
| 426 | -**Settings → Search engines** (`/settings/admin/search-engines`) shows whether IndexNow is on and its key file, how many pages and public repositories it tracks and how many are waiting, and the newest submissions: when, how many URLs, IndexNow's answer and how long it took, any error, and the pages sent. The newest 200 submissions are kept. The same is available over the API: | |
| 426 | +**Settings → Search engines** (`/settings/admin/search-engines`) shows whether IndexNow is on and its key file, how many pages and public repositories it tracks and how many are waiting, and the newest submissions: when, how many URLs, which endpoint answered, its answer and how long it took, any error, and the pages sent. The newest 200 submissions are kept. The same is available over the API: | |
| 427 | 427 | |
| 428 | 428 | ```bash |
| 429 | 429 | diffinery api GET "/diffinery/admin/indexnow?per_page=10" |
addedapps/diffinery/migrations/0040_indexnow_endpoint.sql+3−0View file
| @@ -1,0 +1,3 @@ | ||
| 1 | +-- The IndexNow endpoint that answered a submission (web/indexnow.ts tries the next one when an endpoint | |
| 2 | +-- rate-limits the Worker's shared outbound addresses or can't be reached). | |
| 3 | +ALTER TABLE indexnow_submissions ADD COLUMN endpoint TEXT; |
modifiedapps/diffinery/src/docs/api-meta.ts+2−2View file
| @@ -159,7 +159,7 @@ | ||
| 159 | 159 | export const API_DOCS: Record<string, OpDoc> = { |
| 160 | 160 | // ------------------------------------------------------------ meta, users and organizations |
| 161 | 161 | "GET /": { summary: "API root", description: "Names the API and links to these docs and the MCP endpoint.", access: "public" }, |
| 162 | - "GET /meta": { summary: "Instance metadata", description: "Whether this instance issues OIDC tokens to workflows, and the runner image its last deploy set (`runner_image`; each job reports the image it ran on as `runner.image`), and whether it tells search engines about changed public pages through IndexNow (`indexnow`: `enabled`, `key_location` and the time, URL count and IndexNow status of the `last_submission`; see [Site administration](/docs/admin/site-administration#search-engines)).", access: "public" }, | |
| 162 | + "GET /meta": { summary: "Instance metadata", description: "Whether this instance issues OIDC tokens to workflows, and the runner image its last deploy set (`runner_image`; each job reports the image it ran on as `runner.image`), and whether it tells search engines about changed public pages through IndexNow (`indexnow`: `enabled`, `key_location` and the time, URL count, IndexNow status and `endpoint` of the `last_submission`; see [Site administration](/docs/admin/site-administration#search-engines)).", access: "public" }, | |
| 163 | 163 | "GET /rate_limit": { |
| 164 | 164 | summary: "Get rate limit status", |
| 165 | 165 | description: "For GitHub clients that check their budget before calling. It always reports a full, constant budget (15000 core requests an hour). Diffinery's real limits are per minute (2,000 requests per account, 60 new repositories, issues, pull requests, comments and the like, 300 requests per IP address without signing in) and answer 429 with `retry-after: 60`; see /docs/reference/rest-api#rate-limits.", |
| @@ -1653,7 +1653,7 @@ | ||
| 1653 | 1653 | }, |
| 1654 | 1654 | "GET /diffinery/admin/indexnow": { |
| 1655 | 1655 | summary: "IndexNow submissions", |
| 1656 | - description: "What this instance has told search engines through IndexNow: `enabled` (and `off_reason` when it isn't), `key_location`, how many `site_pages` and public `repositories` it tracks, how many are `queued`, `retry_at` (set after IndexNow refused or couldn't be reached), `repositories_checked_at`, and the newest `submissions` (`at`, `urls`, `status` from IndexNow or null when unreachable, `ms`, `error`, and the first 50 `paths`). See [Search engines](/docs/admin/site-administration#search-engines).", | |
| 1656 | + description: "What this instance has told search engines through IndexNow: `enabled` (and `off_reason` when it isn't), `key_location`, how many `site_pages` and public `repositories` it tracks, how many are `queued`, `retry_at` (set after IndexNow refused or couldn't be reached), `repositories_checked_at`, and the newest `submissions` (`at`, `urls`, `status` from IndexNow or null when unreachable, `endpoint` (the host that answered), `ms`, `error`, and the first 50 `paths`). See [Search engines](/docs/admin/site-administration#search-engines).", | |
| 1657 | 1657 | access: "site-admin", |
| 1658 | 1658 | query: { per_page: ["integer", "Submissions to return (default 50, up to 200)."] }, |
| 1659 | 1659 | }, |
modifiedapps/diffinery/src/web/account.tsx+1−1View file
| @@ -1991,7 +1991,7 @@ | ||
| 1991 | 1991 | </td> |
| 1992 | 1992 | <td>{r.urls}</td> |
| 1993 | 1993 | <td class="small"> |
| 1994 | - <span class={`badge${r.status === 200 || r.status === 202 ? " green" : " red"}`}>{r.status ?? "unreachable"}</span> <span class="muted">{r.ms} ms</span> | |
| 1994 | + <span class={`badge${r.status === 200 || r.status === 202 ? " green" : " red"}`}>{r.status ?? "unreachable"}</span> <span class="muted">{r.endpoint ? `${r.endpoint}, ` : ""}{r.ms} ms</span> | |
| 1995 | 1995 | {r.error ? <div class="mono muted">{r.error}</div> : null} |
| 1996 | 1996 | </td> |
| 1997 | 1997 | <td class="small mono" style="max-width:520px"> |
modifiedapps/diffinery/src/web/indexnow.ts+40−19View file
| @@ -13,8 +13,9 @@ | ||
| 13 | 13 | * queued. Only repositories that are public at that moment, with an owner who isn't suspended, are ever |
| 14 | 14 | * submitted (the same ones the sitemap lists), and only in the same run that saw them public: a |
| 15 | 15 | * private repository's page, or anything behind sign-in, is never sent. |
| 16 | - * - One POST to api.indexnow.org per run, at most MAX_URLS URLs (IndexNow takes 10,000). A refusal or a | |
| 17 | - * failure to reach IndexNow keeps the pages queued and waits before trying again; it never touches a | |
| 16 | + * - One POST per run, at most MAX_URLS URLs (IndexNow takes 10,000), to api.indexnow.org, or to Bing's or | |
| 17 | + * Yandex's own endpoint when it is rate-limited or down (INDEXNOW_ENDPOINTS). A refusal or a failure to | |
| 18 | + * reach IndexNow keeps the pages queued and waits before trying again; it never touches a | |
| 18 | 19 | * deploy, which finished before the cron noticed it. Each submission is logged (console and |
| 19 | 20 | * indexnow_submissions, shown on Settings → Search engines). |
| 20 | 21 | * |
| @@ -30,7 +31,13 @@ | ||
| 30 | 31 | import { sha256 } from "../lib/util.js"; |
| 31 | 32 | import { homeContent, homeMarkdown, sitePageMarkdown, sitePages } from "./site.js"; |
| 32 | 33 | |
| 33 | -export const INDEXNOW_ENDPOINT = "https://api.indexnow.org/indexnow"; | |
| 34 | +/** | |
| 35 | + * Where submissions go, in order. IndexNow engines share what each one receives, so one accepted | |
| 36 | + * submission reaches them all. The next endpoint is tried only when one answers 429 or 5xx or can't be | |
| 37 | + * reached: rate limits are per IP address, and a Worker's outbound addresses are shared with every | |
| 38 | + * other Worker, so api.indexnow.org may turn it away while an engine's own endpoint takes it. | |
| 39 | + */ | |
| 40 | +export const INDEXNOW_ENDPOINTS = ["https://api.indexnow.org/indexnow", "https://www.bing.com/indexnow", "https://yandex.com/indexnow"]; | |
| 34 | 41 | /** URLs per submission: IndexNow's limit. */ |
| 35 | 42 | export const MAX_URLS = 10_000; |
| 36 | 43 | /** Public repositories checked (and listed in the sitemap). */ |
| @@ -198,9 +205,12 @@ | ||
| 198 | 205 | |
| 199 | 206 | // ---------------------------------------------------------------- submitting |
| 200 | 207 | |
| 201 | -export type Submission = { at: string; urls: number; status: number | null; ms: number; error: string | null; paths: string[] }; | |
| 208 | +export type Submission = { at: string; urls: number; status: number | null; ms: number; error: string | null; paths: string[]; endpoint: string | null }; | |
| 202 | 209 | |
| 203 | -/** POSTs `paths` to IndexNow. Never throws. */ | |
| 210 | +/** The host of an endpoint, as logged. */ | |
| 211 | +const hostOf = (url: string) => new URL(url).host; | |
| 212 | + | |
| 213 | +/** POSTs `paths` to IndexNow, trying the next endpoint after a 429, a 5xx or no answer. Never throws. */ | |
| 204 | 214 | export async function submit(env: Pick<Env, "PUBLIC_URL">, key: string, paths: string[], fetcher: typeof fetch = fetch): Promise<Submission> { |
| 205 | 215 | const o = origin(env); |
| 206 | 216 | const at = new Date().toISOString(); |
| @@ -208,15 +218,26 @@ | ||
| 208 | 218 | const body = JSON.stringify({ host: new URL(o).host, key, keyLocation: keyLocation(env, key), urlList: paths.map((p) => `${o}${p}`) }); |
| 209 | 219 | let status: number | null = null; |
| 210 | 220 | let error: string | null = null; |
| 211 | - try { | |
| 212 | - const res = await fetcher(INDEXNOW_ENDPOINT, { method: "POST", headers: { "content-type": "application/json; charset=utf-8" }, body, signal: AbortSignal.timeout(TIMEOUT_MS) }); | |
| 213 | - status = res.status; | |
| 214 | - const text = (await res.text().catch(() => "")).replace(/\s+/g, " ").trim(); | |
| 215 | - if (status !== 200 && status !== 202) error = text.slice(0, 500) || res.statusText || `HTTP ${status}`; | |
| 216 | - } catch (e) { | |
| 217 | - error = (e instanceof Error ? `${e.name}: ${e.message}` : String(e)).slice(0, 500); | |
| 221 | + let endpoint: string | null = null; | |
| 222 | + const passed: string[] = []; | |
| 223 | + for (const url of INDEXNOW_ENDPOINTS) { | |
| 224 | + if (endpoint) passed.push(`${endpoint} ${status ?? "unreachable"}${error ? ` (${error.slice(0, 120)})` : ""}`); | |
| 225 | + endpoint = hostOf(url); | |
| 226 | + status = null; | |
| 227 | + error = null; | |
| 228 | + try { | |
| 229 | + const res = await fetcher(url, { method: "POST", headers: { "content-type": "application/json; charset=utf-8" }, body, signal: AbortSignal.timeout(TIMEOUT_MS) }); | |
| 230 | + status = res.status; | |
| 231 | + const text = (await res.text().catch(() => "")).replace(/\s+/g, " ").trim(); | |
| 232 | + if (status !== 200 && status !== 202) error = text.slice(0, 500) || res.statusText || `HTTP ${status}`; | |
| 233 | + } catch (e) { | |
| 234 | + error = (e instanceof Error ? `${e.name}: ${e.message}` : String(e)).slice(0, 500); | |
| 235 | + } | |
| 236 | + if (status !== null && status !== 429 && status < 500) break; | |
| 218 | 237 | } |
| 219 | - return { at, urls: paths.length, status, ms: Date.now() - started, error, paths }; | |
| 238 | + // What the endpoints passed over said, then the last one's own error. | |
| 239 | + if (passed.length) error = [`after ${passed.join("; ")}`, error].filter(Boolean).join(": "); | |
| 240 | + return { at, urls: paths.length, status, ms: Date.now() - started, error: error?.slice(0, 1000) ?? null, paths, endpoint }; | |
| 220 | 241 | } |
| 221 | 242 | |
| 222 | 243 | const accepted = (s: Submission) => s.status === 200 || s.status === 202; |
| @@ -249,9 +270,9 @@ | ||
| 249 | 270 | } |
| 250 | 271 | const batch = all.slice(0, MAX_URLS); |
| 251 | 272 | const s = await submit(env, await key, batch.map((r) => r.path), fetcher); |
| 252 | - console.log(`indexnow: ${s.urls} URLs, ${s.status ?? "unreachable"} in ${s.ms} ms${s.error ? `: ${s.error}` : ""}`); | |
| 273 | + console.log(`indexnow: ${s.urls} URLs, ${s.endpoint} ${s.status ?? "unreachable"} in ${s.ms} ms${s.error ? `: ${s.error}` : ""}`); | |
| 253 | 274 | const stmts: D1PreparedStatement[] = [ |
| 254 | - db.prepare("INSERT INTO indexnow_submissions (at, urls, status, ms, error, paths) VALUES (?, ?, ?, ?, ?, ?)").bind(s.at, s.urls, s.status, s.ms, s.error, JSON.stringify(s.paths.slice(0, 50))), | |
| 275 | + db.prepare("INSERT INTO indexnow_submissions (at, urls, status, ms, error, paths, endpoint) VALUES (?, ?, ?, ?, ?, ?, ?)").bind(s.at, s.urls, s.status, s.ms, s.error, JSON.stringify(s.paths.slice(0, 50)), s.endpoint), | |
| 255 | 276 | db.prepare("DELETE FROM indexnow_submissions WHERE id <= (SELECT id FROM indexnow_submissions ORDER BY id DESC LIMIT 1 OFFSET ?)").bind(KEEP_SUBMISSIONS), |
| 256 | 277 | ]; |
| 257 | 278 | if (accepted(s)) { |
| @@ -308,16 +329,16 @@ | ||
| 308 | 329 | } |
| 309 | 330 | |
| 310 | 331 | export async function lastSubmissions(db: D1Database, limit: number): Promise<Submission[]> { |
| 311 | - const rows = (await db.prepare("SELECT at, urls, status, ms, error, paths FROM indexnow_submissions ORDER BY id DESC LIMIT ?").bind(limit).all<Omit<Submission, "paths"> & { paths: string }>()).results; | |
| 332 | + const rows = (await db.prepare("SELECT at, urls, status, ms, error, paths, endpoint FROM indexnow_submissions ORDER BY id DESC LIMIT ?").bind(limit).all<Omit<Submission, "paths"> & { paths: string }>()).results; | |
| 312 | 333 | return rows.map((r) => ({ ...r, paths: JSON.parse(r.paths) as string[] })); |
| 313 | 334 | } |
| 314 | 335 | |
| 315 | 336 | /** For GET /api/v3/meta: whether IndexNow is on, its key file, and how the last submission went (no URLs). */ |
| 316 | -export async function indexNowMeta(env: IndexNowEnv): Promise<{ enabled: boolean; key_location: string | null; last_submission: { at: string; urls: number; status: number | null } | null }> { | |
| 337 | +export async function indexNowMeta(env: IndexNowEnv): Promise<{ enabled: boolean; key_location: string | null; last_submission: { at: string; urls: number; status: number | null; endpoint: string | null } | null }> { | |
| 317 | 338 | const key = indexNowKey(env); |
| 318 | 339 | if (!key) return { enabled: false, key_location: null, last_submission: null }; |
| 319 | - const last = await env.DB.prepare("SELECT at, urls, status FROM indexnow_submissions ORDER BY id DESC LIMIT 1") | |
| 320 | - .first<{ at: string; urls: number; status: number | null }>() | |
| 340 | + const last = await env.DB.prepare("SELECT at, urls, status, endpoint FROM indexnow_submissions ORDER BY id DESC LIMIT 1") | |
| 341 | + .first<{ at: string; urls: number; status: number | null; endpoint: string | null }>() | |
| 321 | 342 | .catch(() => null); |
| 322 | 343 | return { enabled: true, key_location: keyLocation(env, await key), last_submission: last ?? null }; |
| 323 | 344 | } |
modifiedapps/diffinery/test/unit/indexnow.test.ts+34−9View file
| @@ -10,7 +10,7 @@ | ||
| 10 | 10 | import type { Repo } from "../../src/lib/directory.js"; |
| 11 | 11 | import { docsSitemapPaths } from "../../src/docs/web.js"; |
| 12 | 12 | import { siteSitemapEntries } from "../../src/web/site.js"; |
| 13 | -import { indexNowCron, indexNowKey, indexNowOff, indexNowStatus, MAX_URLS, RETRY_MS, sitePageContents } from "../../src/web/indexnow.js"; | |
| 13 | +import { INDEXNOW_ENDPOINTS, indexNowCron, indexNowKey, indexNowOff, indexNowStatus, MAX_URLS, RETRY_MS, sitePageContents } from "../../src/web/indexnow.js"; | |
| 14 | 14 | import { testD1 } from "./stubs/d1.js"; |
| 15 | 15 | |
| 16 | 16 | app.route("/", web); |
| @@ -21,16 +21,18 @@ | ||
| 21 | 21 | |
| 22 | 22 | type Sent = { host: string; key: string; keyLocation: string; urlList: string[] }; |
| 23 | 23 | |
| 24 | -function setup(o: { status?: number | (() => number); version?: string } = {}) { | |
| 24 | +function setup(o: { status?: number | ((url: string) => number); version?: string } = {}) { | |
| 25 | 25 | const db = testD1(); |
| 26 | 26 | const env = { PUBLIC_URL: ORIGIN, MASTER_KEY, DB: db, CF_VERSION_METADATA: o.version ? { id: "v", tag: "", timestamp: o.version } : undefined } as unknown as Env; |
| 27 | 27 | const sent: Sent[] = []; |
| 28 | + const urls: string[] = []; | |
| 28 | 29 | let status = o.status ?? 200; |
| 29 | 30 | const fetcher = (async (url: string, init: RequestInit) => { |
| 30 | - expect(url).toBe("https://api.indexnow.org/indexnow"); | |
| 31 | + expect(INDEXNOW_ENDPOINTS).toContain(url); | |
| 31 | 32 | expect(new Headers(init.headers).get("content-type")).toBe("application/json; charset=utf-8"); |
| 33 | + urls.push(url); | |
| 32 | 34 | sent.push(JSON.parse(init.body as string) as Sent); |
| 33 | - const s = typeof status === "function" ? status() : status; | |
| 35 | + const s = typeof status === "function" ? status(url) : status; | |
| 34 | 36 | return new Response(s === 200 ? "" : "nope", { status: s }); |
| 35 | 37 | }) as unknown as typeof fetch; |
| 36 | 38 | let repos: Array<Partial<Repo>> = []; |
| @@ -39,11 +41,12 @@ | ||
| 39 | 41 | env, |
| 40 | 42 | db, |
| 41 | 43 | sent, |
| 44 | + urls, | |
| 42 | 45 | run: () => indexNowCron(env as never, dir, fetcher), |
| 43 | 46 | setRepos: (r: Array<Partial<Repo>>) => (repos = r), |
| 44 | 47 | /** A new version, as a deploy makes (its content is the same build, so tests change what was last submitted instead). */ |
| 45 | 48 | deploy: (timestamp: string) => ((env as { CF_VERSION_METADATA?: WorkerVersionMetadata }).CF_VERSION_METADATA = { id: `v-${timestamp}`, tag: "", timestamp }), |
| 46 | - setStatus: (s: number) => (status = s), | |
| 49 | + setStatus: (s: number | ((url: string) => number)) => (status = s), | |
| 47 | 50 | state: (k: string) => db.sqlite.prepare("SELECT v FROM indexnow_state WHERE k = ?").get(k) as { v: string } | undefined, |
| 48 | 51 | /** Moves time forward for the cron: every stored time goes back by `ms`. */ |
| 49 | 52 | age: (ms: number) => { |
| @@ -184,12 +187,14 @@ | ||
| 184 | 187 | it("keeps pages queued after a refusal and waits before trying again", async () => { |
| 185 | 188 | t.setStatus(429); |
| 186 | 189 | const s = await t.run(); |
| 187 | - expect(s).toMatchObject({ status: 429, error: "nope" }); | |
| 190 | + // Every endpoint was tried once, in order. | |
| 191 | + expect(t.urls).toEqual(INDEXNOW_ENDPOINTS); | |
| 192 | + expect(s).toMatchObject({ status: 429, endpoint: "yandex.com", error: "after api.indexnow.org 429 (nope); www.bing.com 429 (nope): nope" }); | |
| 188 | 193 | const retry = Date.parse(t.state("retry_at")!.v); |
| 189 | 194 | expect(retry - Date.now()).toBeGreaterThan(RETRY_MS.rateLimited - 60_000); |
| 190 | 195 | // Waiting: nothing sent. |
| 191 | 196 | expect(await t.run()).toBeNull(); |
| 192 | - expect(t.sent).toHaveLength(1); | |
| 197 | + expect(t.sent).toHaveLength(3); | |
| 193 | 198 | // Once the wait is over, the same pages go again. |
| 194 | 199 | t.setStatus(202); |
| 195 | 200 | t.age(RETRY_MS.rateLimited); |
| @@ -198,15 +203,35 @@ | ||
| 198 | 203 | expect(await t.run()).toBeNull(); |
| 199 | 204 | const st = await indexNowStatus(t.env as never); |
| 200 | 205 | expect(st).toMatchObject({ off: null, queued: 0, retryAt: null }); |
| 201 | - expect(st.submissions.map((x) => x.status)).toEqual([202, 429]); | |
| 206 | + expect(st.submissions.map((x) => [x.status, x.endpoint])).toEqual([ | |
| 207 | + [202, "api.indexnow.org"], | |
| 208 | + [429, "yandex.com"], | |
| 209 | + ]); | |
| 202 | 210 | }); |
| 203 | 211 | |
| 212 | + it("tries the next endpoint when one rate-limits or fails, but not after a refusal", async () => { | |
| 213 | + t.setStatus((url) => (url.includes("api.indexnow.org") ? 429 : 200)); | |
| 214 | + const s = await t.run(); | |
| 215 | + expect(s).toMatchObject({ status: 200, endpoint: "www.bing.com", error: "after api.indexnow.org 429 (nope)" }); | |
| 216 | + expect(t.urls).toEqual(INDEXNOW_ENDPOINTS.slice(0, 2)); | |
| 217 | + // The pages count as submitted. | |
| 218 | + expect(await t.run()).toBeNull(); | |
| 219 | + // A 403 (key not valid) or 422 is the same everywhere: no other endpoint is tried. | |
| 220 | + t.db.sqlite.prepare("UPDATE indexnow_urls SET sent_hash = 'old' WHERE path = '/features'").run(); | |
| 221 | + t.db.sqlite.prepare("INSERT INTO indexnow_state (k, v) VALUES ('retry_at', '2000-01-01T00:00:00Z')").run(); | |
| 222 | + t.setStatus(403); | |
| 223 | + expect(await t.run()).toMatchObject({ status: 403, endpoint: "api.indexnow.org", error: "nope" }); | |
| 224 | + expect(t.urls).toHaveLength(3); | |
| 225 | + expect(Date.parse(t.state("retry_at")!.v) - Date.now()).toBeGreaterThan(RETRY_MS.refused - 60_000); | |
| 226 | + }); | |
| 227 | + | |
| 204 | 228 | it("never throws when IndexNow can't be reached, and tries again later", async () => { |
| 205 | 229 | const fetcher = (async () => { |
| 206 | 230 | throw new TypeError("network down"); |
| 207 | 231 | }) as unknown as typeof fetch; |
| 208 | 232 | const s = await indexNowCron(t.env as never, { publicRepos: async () => [] }, fetcher); |
| 209 | - expect(s).toMatchObject({ status: null, error: "TypeError: network down" }); | |
| 233 | + expect(s).toMatchObject({ status: null, endpoint: "yandex.com" }); | |
| 234 | + expect(s!.error).toMatch(/^after api.indexnow.org unreachable \(TypeError: network down\); www.bing.com unreachable .*: TypeError: network down$/); | |
| 210 | 235 | expect(Date.parse(t.state("retry_at")!.v)).toBeGreaterThan(Date.now()); |
| 211 | 236 | // A broken database is logged, not thrown. |
| 212 | 237 | const broken = { ...t.env, DB: { prepare: () => { throw new Error("D1 down"); } } }; |