From 01f6f81bda2f122455167c6e92df0ae5d22e93b2 Mon Sep 17 00:00:00 2001 From: svemagie <869694+svemagie@users.noreply.github.com> Date: Mon, 23 Mar 2026 08:39:46 +0100 Subject: [PATCH] fix(mastodon-api): favourite/reblog blocks on unbound resolveAuthor HTTP requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit likePost, unlikePost and boostPost all call resolveAuthor() which makes up to 3 signed HTTP requests to the remote server (post fetch, author actor fetch, getAttributedTo) with no timeout. If the remote server is slow or unreachable, the favourite/reblog HTTP response hangs until the Node.js socket default times out (~2 min). Mastodon clients (Phanpy, Elk) give up much sooner and show "Failed to load post". Fix: wrap every resolveAuthor() call in a Promise.race() with a 5 s timeout. The interaction is still recorded in ap_interactions and the Like/Announce activity is still sent when recipient resolution succeeds within the window; if it times out, AP delivery is silently skipped (the local record is kept — the client sees a successful ⭐). Co-Authored-By: Claude Sonnet 4.6 --- lib/mastodon/helpers/interactions.js | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/lib/mastodon/helpers/interactions.js b/lib/mastodon/helpers/interactions.js index 3072b02..f8b702c 100644 --- a/lib/mastodon/helpers/interactions.js +++ b/lib/mastodon/helpers/interactions.js @@ -30,7 +30,15 @@ export async function likePost({ targetUrl, federation, handle, publicationUrl, ); const documentLoader = await ctx.getDocumentLoader({ identifier: handle }); - const recipient = await resolveAuthor(targetUrl, ctx, documentLoader, collections); + // resolveAuthor makes up to 3 signed HTTP requests to the remote server. + // Cap at 5 s so a slow/unreachable remote never blocks the client response. + let recipient = null; + try { + recipient = await Promise.race([ + resolveAuthor(targetUrl, ctx, documentLoader, collections), + new Promise((_, reject) => setTimeout(() => reject(new Error("resolveAuthor timeout")), 5000)), + ]); + } catch { /* skip AP delivery — interaction is still recorded locally */ } const uuid = crypto.randomUUID(); const baseUrl = publicationUrl.replace(/\/$/, ""); @@ -95,7 +103,13 @@ export async function unlikePost({ targetUrl, federation, handle, publicationUrl ); const documentLoader = await ctx.getDocumentLoader({ identifier: handle }); - const recipient = await resolveAuthor(targetUrl, ctx, documentLoader, collections); + let recipient = null; + try { + recipient = await Promise.race([ + resolveAuthor(targetUrl, ctx, documentLoader, collections), + new Promise((_, reject) => setTimeout(() => reject(new Error("resolveAuthor timeout")), 5000)), + ]); + } catch { /* skip AP delivery */ } if (recipient) { const like = new Like({ @@ -160,9 +174,15 @@ export async function boostPost({ targetUrl, federation, handle, publicationUrl, orderingKey: targetUrl, }); - // Also send directly to the original post author + // Also send directly to the original post author (best-effort, 5 s cap) const documentLoader = await ctx.getDocumentLoader({ identifier: handle }); - const recipient = await resolveAuthor(targetUrl, ctx, documentLoader, collections); + let recipient = null; + try { + recipient = await Promise.race([ + resolveAuthor(targetUrl, ctx, documentLoader, collections), + new Promise((_, reject) => setTimeout(() => reject(new Error("resolveAuthor timeout")), 5000)), + ]); + } catch { /* skip author delivery — follower delivery already happened */ } if (recipient) { try { await ctx.sendActivity({ identifier: handle }, recipient, announce, {