From f517cb23fa37b15922c8ad333952df09847ce8b6 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 30 Aug 2026 23:10:28 -0700 Subject: A first-tap Safari hiccup stops costing a hat sale: the shop's registrar posts retry once on network-shaped failures (Safari's bare 'Load failed' included — fresh NIP-98 header per attempt, /hats/claim already idempotent, a real refusal never re-posts), and the shop listing quietly retries once before declaring itself offline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01MrkLsNScXe9b6BviUSdmMr --- src/features/hats.js | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) (limited to 'src') diff --git a/src/features/hats.js b/src/features/hats.js index 2b4a4d6..37997f4 100644 --- a/src/features/hats.js +++ b/src/features/hats.js @@ -185,16 +185,27 @@ export function hatsFeature(ctx) { return pk ? { pubkey: pk, signer: { signEvent: (e) => wallet.nostrSign(e) } } : null; } - async function post(path, payload, signer) { + // Safari reports a dropped request as the bare "Load failed", and a fresh + // PWA's first tap is prime territory for exactly one of those. Retry a + // NETWORK-shaped failure once, transparently (a fresh NIP-98 header each + // attempt); a real refusal from the registrar never re-posts. Safe for + // both posts: /hats/invoice mints anew, /hats/claim is idempotent. + const NETWORKISH = /load failed|failed to fetch|network|timed? ?out/i; + async function post(path, payload, signer, attempt = 0) { const url = `${REGISTRAR}${path}`; const body = JSON.stringify(payload); - const auth = await withTimeout(nip98Header(signer, url, 'POST', body), 12000, 'signing'); - const r = await withTimeout(fetch(url, { - method: 'POST', headers: { 'content-type': 'application/json', authorization: auth }, body, - }), 12000, 'registrar'); - const j = await r.json().catch(() => ({})); - if (!r.ok || j.error) throw new Error(j.error || `registrar refused (${r.status})`); - return j; + try { + const auth = await withTimeout(nip98Header(signer, url, 'POST', body), 12000, 'signing'); + const r = await withTimeout(fetch(url, { + method: 'POST', headers: { 'content-type': 'application/json', authorization: auth }, body, + }), 12000, 'registrar'); + const j = await r.json().catch(() => ({})); + if (!r.ok || j.error) throw new Error(j.error || `registrar refused (${r.status})`); + return j; + } catch (e) { + if (attempt < 1 && NETWORKISH.test(e.message || '')) return post(path, payload, signer, attempt + 1); + throw e; + } } // ---- the shop ----------------------------------------------------------- @@ -210,7 +221,7 @@ export function hatsFeature(ctx) { render(); const me = ctx.shownPubkey(); if (!me) { ui.hatShopData = { owned: [], equipped: null }; render(); return; } - fetch(`${REGISTRAR}/hats/${me}?net=${hatNet()}`).then((r) => r.json()).then((j) => { + const attempt = (retriesLeft) => fetch(`${REGISTRAR}/hats/${me}?net=${hatNet()}`).then((r) => r.json()).then((j) => { if (!ui.hatShop) return; ui.hatShopData = { owned: j.owned || [], equipped: j.equipped || null, prices: j.prices || null }; const now = Date.now(); @@ -218,8 +229,11 @@ export function hatsFeature(ctx) { saveStore(); render(); }).catch(() => { + // one quiet retry before showing the offline shop — see post() + if (retriesLeft > 0) return new Promise((r) => setTimeout(r, 1200)).then(() => attempt(retriesLeft - 1)); if (ui.hatShop && !ui.hatShopData) { ui.hatShopData = { owned: [], equipped: null, offline: true }; render(); } }); + attempt(1); } const closeShop = () => { ui.hatShop = null; ui.hatShopData = null; ui.hatConfirm = null; render(); }; -- cgit v1.2.3