fix(federation): friend-add returns graceful 503 instead of 500 on peer lookup failure (BUG-3)

lookupRemoteUser now maps peer HTTP failures (403/5xx, malformed body) to a
structured {ok:false,reason:'unreachable'} instead of throwing, and the
federated friend-add wraps the call in try/catch as defense-in-depth. A
desynced/unreachable peer no longer surfaces as a raw 500 on a user action.
README.md left unstaged.
This commit is contained in:
Jannis Braun
2026-07-02 11:23:08 +02:00
parent 43d1dad1d7
commit 6de14b281b
4 changed files with 68 additions and 9 deletions
@@ -202,7 +202,33 @@ describe('lookupRemoteUser', () => {
expect(headers['X-Federation-Timestamp']).toMatch(/^\d+$/);
});
it('8. throws on malformed 200 body', async () => {
it('9. returns ok:false reason:unreachable on HTTP 403 (peer rejects our auth / desync) — must NOT throw', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false,
status: 403,
headers: { get: () => null },
}));
const { lookupRemoteUser } = await import('./federationLookup.js');
const result = await lookupRemoteUser(PEER_ORIGIN, 'bob');
expect(result).toEqual({ ok: false, reason: 'unreachable' });
});
it('10. returns ok:false reason:unreachable on HTTP 500 (peer error) — must NOT throw', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false,
status: 500,
headers: { get: () => null },
}));
const { lookupRemoteUser } = await import('./federationLookup.js');
const result = await lookupRemoteUser(PEER_ORIGIN, 'bob');
expect(result).toEqual({ ok: false, reason: 'unreachable' });
});
it('8. returns ok:false reason:unreachable on malformed 200 body — must NOT throw', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
status: 200,
@@ -211,8 +237,7 @@ describe('lookupRemoteUser', () => {
}));
const { lookupRemoteUser } = await import('./federationLookup.js');
await expect(lookupRemoteUser(PEER_ORIGIN, 'bob')).rejects.toThrow(
`lookupRemoteUser: peer ${PEER_ORIGIN} returned malformed body`,
);
const result = await lookupRemoteUser(PEER_ORIGIN, 'bob');
expect(result).toEqual({ ok: false, reason: 'unreachable' });
});
});
+13 -3
View File
@@ -60,12 +60,22 @@ export async function lookupRemoteUser(peerOrigin: string, username: string): Pr
}
if (!response.ok) {
throw new Error(`lookupRemoteUser: peer ${peerOrigin} returned HTTP ${response.status}`);
// Any non-2xx that isn't 404 (not_found) or 429 (rate_limited) — e.g. 403
// (peer rejects our HMAC: revoked, not-yet-active, or a post-reset secret
// desync) or 5xx (peer error) — is treated as `unreachable`, NOT thrown.
// A peer's auth/transport failure must never surface as an unhandled 500 on
// a user action (e.g. a federated friend-add); callers already map
// `unreachable` to a graceful 503. Logged for operators.
console.warn(`[federation] lookupRemoteUser: peer ${peerOrigin} returned HTTP ${response.status} — treating as unreachable`);
return { ok: false, reason: 'unreachable' };
}
const json = (await response.json()) as FederationUserLookupResponse;
const json = (await response.json().catch(() => null)) as FederationUserLookupResponse | null;
if (!json || json.found !== true || !json.user || typeof json.user.homeUserId !== 'string') {
throw new Error(`lookupRemoteUser: peer ${peerOrigin} returned malformed body`);
// A malformed / non-JSON 200 body is peer misbehavior — surface as
// unreachable rather than throwing (same reasoning as above).
console.warn(`[federation] lookupRemoteUser: peer ${peerOrigin} returned malformed body — treating as unreachable`);
return { ok: false, reason: 'unreachable' };
}
return {