feat(journal): honor 429/Retry-After in outbox polling (§7.4)
Completes the 7.4 remainder. Fedify's FetchError carries the failed Response, so a 429 from a remote's outbox now reads Retry-After (delta-seconds or HTTP-date per RFC 9110), arms a per-host backoff (15 min default, capped at the 1 h poll interval), and pollRemoteActor skips backing-off hosts before doing any DB or network work. State is in-process like the pacing map — a restart costs at most one extra request that re-arms the backoff. last_polled_at is deliberately not stamped on a rate-limited poll so the hourly sweep retries. Unit tests cover the header parser (incl. the V8 footgun where Date.parse reads bare negative integers as years), default/cap behavior, and window expiry; an integration test drives the full FetchError path and asserts the second poll never touches the network. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
60afacc69e
commit
3c2bdfd2bd
4 changed files with 161 additions and 6 deletions
|
|
@ -1,8 +1,15 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
|
||||
vi.mock("./db.ts", () => ({ getDb: () => ({}) }));
|
||||
|
||||
const { parseOutboxItem } = await import("./federation-ingest.server.ts");
|
||||
const {
|
||||
parseOutboxItem,
|
||||
parseRetryAfter,
|
||||
noteHostRateLimited,
|
||||
isHostBackingOff,
|
||||
resetHostBackoff,
|
||||
pollRemoteActor,
|
||||
} = await import("./federation-ingest.server.ts");
|
||||
|
||||
function trailsCreate(overrides: Record<string, unknown> = {}, noteOverrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
|
|
@ -78,3 +85,47 @@ describe("parseOutboxItem", () => {
|
|||
expect(parsed?.name).not.toContain("<b>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("429 backoff (7.4)", () => {
|
||||
afterEach(() => resetHostBackoff());
|
||||
|
||||
const NOW = Date.parse("2026-06-07T12:00:00Z");
|
||||
|
||||
it("parses Retry-After as delta-seconds and HTTP-date", () => {
|
||||
expect(parseRetryAfter("120", NOW)).toBe(120_000);
|
||||
expect(parseRetryAfter(" 5 ", NOW)).toBe(5_000);
|
||||
expect(parseRetryAfter("Sun, 07 Jun 2026 12:01:00 GMT", NOW)).toBe(60_000);
|
||||
// past HTTP-date clamps to zero
|
||||
expect(parseRetryAfter("Sun, 07 Jun 2026 11:00:00 GMT", NOW)).toBe(0);
|
||||
expect(parseRetryAfter(null, NOW)).toBeNull();
|
||||
expect(parseRetryAfter("soon", NOW)).toBeNull();
|
||||
expect(parseRetryAfter("-5", NOW)).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to the default and caps absurd Retry-After values", () => {
|
||||
// default (no header): 15 min
|
||||
expect(noteHostRateLimited("a.example", null, NOW)).toBe(15 * 60 * 1000);
|
||||
// honored when reasonable
|
||||
expect(noteHostRateLimited("b.example", "120", NOW)).toBe(120_000);
|
||||
// capped at the poll interval (1 h)
|
||||
expect(noteHostRateLimited("c.example", String(7 * 24 * 3600), NOW)).toBe(60 * 60 * 1000);
|
||||
});
|
||||
|
||||
it("backs off the host until the window expires", () => {
|
||||
noteHostRateLimited("d.example", "60", NOW);
|
||||
expect(isHostBackingOff("d.example", NOW + 59_000)).toBe(true);
|
||||
expect(isHostBackingOff("d.example", NOW + 61_000)).toBe(false);
|
||||
// expiry clears the entry; a later check stays false
|
||||
expect(isHostBackingOff("d.example", NOW)).toBe(false);
|
||||
expect(isHostBackingOff("other.example", NOW)).toBe(false);
|
||||
});
|
||||
|
||||
it("pollRemoteActor skips a backing-off host before touching the database", async () => {
|
||||
// getDb is mocked to {} — any query would throw, so reaching the
|
||||
// skip proves the backoff check runs first.
|
||||
noteHostRateLimited("limited.example", "3600");
|
||||
await expect(pollRemoteActor("https://limited.example/users/alice")).resolves.toEqual({
|
||||
skipped: "host rate-limited (backing off)",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue