diff --git a/STANDARD-SITE.md b/STANDARD-SITE.md index 53eede5..e8d915f 100644 --- a/STANDARD-SITE.md +++ b/STANDARD-SITE.md @@ -32,7 +32,13 @@ and angular-buch.com); each parent repo supplies its own config. | `STANDARD_SITE_NAME` | no | Publication name. Default: the handle. | | `STANDARD_SITE_DESCRIPTION` | no | Publication description. | | `STANDARD_SITE_EXPECTED_DID` | no | Abort if the logged-in DID differs (guards against a wrong account). | -| `STANDARD_SITE_ICON` | no | Publication profile image (square ≥256×256, png/jpg/webp). An http(s) URL or a local file path; uploaded as a blob and set as the publication `icon`. | +| `STANDARD_SITE_ICON` | no | Publication profile image (square ≥256×256, png/jpg/webp). An http(s) URL or a local file path; uploaded as a blob and set as the publication `icon`. A missing/broken icon is non-fatal (warns and continues). | + +**Icon convention:** the icon is a website asset, not a shared-build asset. Put a +square `standard-site-icon.png` in the consuming **website** repo's `public/` and +point `STANDARD_SITE_ICON` at its deployed URL +(e.g. `https://example.com/standard-site-icon.png`). Deploy the website before the +article-data build runs so the URL resolves. | `STANDARD_SITE_SHOW_IN_DISCOVER` | no | `false` to opt out of the discovery feed. Default `true`. | | `STANDARD_SITE_DRY_RUN` | no | `true` logs the records that would be written/pruned without touching the PDS. | @@ -68,6 +74,9 @@ and angular-buch.com); each parent repo supplies its own config. deleted. - `validate: false` is used on `putRecord` because a PDS does not natively know third-party lexicons like `site.standard.*`. +- Resilient: transient PDS responses (429, 5xx) are retried with backoff, and a + publish failure is caught and logged rather than thrown — it never fails the + article-data build/deploy. Records catch up on the next successful build. ## Adding another website (e.g. angular-buch.com) diff --git a/build.ts b/build.ts index 538eaa7..6930e22 100644 --- a/build.ts +++ b/build.ts @@ -75,11 +75,20 @@ async function build(): Promise { console.log('\nValidating anchor links...'); printValidationResults(); - // Publish standard.site records (no-op unless configured via env) - await publishStandardSite([ - { contentType: 'blog', entries: blogEntries }, - { contentType: 'material', entries: materialEntries }, - ]); + // Publish standard.site records (no-op unless configured via env). This is a + // secondary side-effect: a publish failure must never block the article-data + // build/deploy, so it is caught and logged rather than thrown. + try { + await publishStandardSite([ + { contentType: 'blog', entries: blogEntries }, + { contentType: 'material', entries: materialEntries }, + ]); + } catch (error) { + console.error( + 'standard.site: publishing failed (continuing build):', + error instanceof Error ? error.message : error, + ); + } console.log('\nBuild complete!'); } diff --git a/standard-site/atproto.ts b/standard-site/atproto.ts index ca64b1f..2ca7344 100644 --- a/standard-site/atproto.ts +++ b/standard-site/atproto.ts @@ -10,11 +10,45 @@ export interface AtpSession { accessJwt: string; } +/** How many attempts (1 initial + retries) for a transient failure. */ +const MAX_ATTEMPTS = 4; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** 429 (rate limit) and 5xx (server) responses are worth retrying. */ +function isTransientStatus(status: number): boolean { + return status === 429 || status >= 500; +} + +/** + * fetch with retries + exponential backoff for transient conditions (network + * errors, 429, 5xx). A PDS blip like a 502 should not fail the whole build. + */ +async function fetchWithRetry(url: string, init: RequestInit): Promise { + for (let attempt = 1; ; attempt++) { + let response: Response; + try { + response = await fetch(url, init); + } catch (error) { + if (attempt >= MAX_ATTEMPTS) { + throw error; + } + await sleep(250 * 2 ** (attempt - 1)); + continue; + } + if (isTransientStatus(response.status) && attempt < MAX_ATTEMPTS) { + await sleep(250 * 2 ** (attempt - 1)); + continue; + } + return response; + } +} + async function xrpc( url: string, init: RequestInit, ): Promise { - const response = await fetch(url, init); + const response = await fetchWithRetry(url, init); const text = await response.text(); if (!response.ok) { throw new Error(`XRPC ${init.method} ${url} failed: ${response.status} ${text}`); @@ -79,7 +113,7 @@ export async function uploadBlob( bytes: Uint8Array, mimeType: string, ): Promise { - const response = await fetch(`${pds}/xrpc/com.atproto.repo.uploadBlob`, { + const response = await fetchWithRetry(`${pds}/xrpc/com.atproto.repo.uploadBlob`, { method: 'POST', headers: { 'content-type': mimeType,