fix(mastodon-api): favourite/reblog blocks on unbound resolveAuthor HTTP requests

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 <noreply@anthropic.com>
This commit is contained in:
svemagie
2026-03-23 08:39:46 +01:00
parent da89554ef9
commit 01f6f81bda

View File

@@ -30,7 +30,15 @@ export async function likePost({ targetUrl, federation, handle, publicationUrl,
); );
const documentLoader = await ctx.getDocumentLoader({ identifier: handle }); 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 uuid = crypto.randomUUID();
const baseUrl = publicationUrl.replace(/\/$/, ""); const baseUrl = publicationUrl.replace(/\/$/, "");
@@ -95,7 +103,13 @@ export async function unlikePost({ targetUrl, federation, handle, publicationUrl
); );
const documentLoader = await ctx.getDocumentLoader({ identifier: handle }); 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) { if (recipient) {
const like = new Like({ const like = new Like({
@@ -160,9 +174,15 @@ export async function boostPost({ targetUrl, federation, handle, publicationUrl,
orderingKey: targetUrl, 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 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) { if (recipient) {
try { try {
await ctx.sendActivity({ identifier: handle }, recipient, announce, { await ctx.sendActivity({ identifier: handle }, recipient, announce, {