From 9cad956cc9359af6dcbaa75674859043285413d0 Mon Sep 17 00:00:00 2001 From: Thejesh Reddy <35212698+thejesh23@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:19:27 -0700 Subject: [PATCH] fix(portals): add a 15s request timeout to every board fetch (#197) None of the board CLIs set a fetch timeout, and the retry loops react only to HTTP status codes, not to a connection that is accepted then never responds (black-holed TCP, hung TLS, stalled proxy) - so await fetch(...) never settles and the command hangs with no output and no exit. freehire's helper even documented a fast-degrade contract its try/catch didn't deliver on a mid-flight stall. Adds signal: AbortSignal.timeout(15000) to every fetch across all six CLIs, with network-free tests asserting the signal is present on each request wrapper. By @thejesh23. Verified: 8 timeout tests pass locally with fetch stubbed (no network), and would fail on the pre-fix code. Closes #196 --- .../skills/freehire-search/cli/src/helpers.ts | 1 + .../cli/tests/request-timeout.test.ts | 26 ++++++++++++++ .../skills/jobbank-search/cli/src/helpers.ts | 1 + .../cli/tests/request-timeout.test.ts | 23 +++++++++++++ .../cli/src/commands/detail.ts | 1 + .../jobdanmark-search/cli/src/helpers.ts | 3 +- .../cli/tests/request-timeout.test.ts | 34 +++++++++++++++++++ .../skills/jobindex-search/cli/src/helpers.ts | 3 +- .../cli/tests/request-timeout.test.ts | 34 +++++++++++++++++++ .../skills/jobnet-search/cli/src/helpers.ts | 1 + .../cli/tests/request-timeout.test.ts | 23 +++++++++++++ .../skills/linkedin-search/cli/src/helpers.ts | 1 + .../cli/tests/request-timeout.test.ts | 23 +++++++++++++ 13 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 .agents/skills/freehire-search/cli/tests/request-timeout.test.ts create mode 100644 .agents/skills/jobbank-search/cli/tests/request-timeout.test.ts create mode 100644 .agents/skills/jobdanmark-search/cli/tests/request-timeout.test.ts create mode 100644 .agents/skills/jobindex-search/cli/tests/request-timeout.test.ts create mode 100644 .agents/skills/jobnet-search/cli/tests/request-timeout.test.ts create mode 100644 .agents/skills/linkedin-search/cli/tests/request-timeout.test.ts diff --git a/.agents/skills/freehire-search/cli/src/helpers.ts b/.agents/skills/freehire-search/cli/src/helpers.ts index 694e5a1..76d1099 100644 --- a/.agents/skills/freehire-search/cli/src/helpers.ts +++ b/.agents/skills/freehire-search/cli/src/helpers.ts @@ -42,6 +42,7 @@ export async function apiGet(path: string): Promise | null> { response = await fetch(url, { headers: { "User-Agent": UA, Accept: "application/json" }, redirect: "follow", + signal: AbortSignal.timeout(15000), }) } catch (e) { // Connection refused / DNS failure / timeout: the API is unreachable. diff --git a/.agents/skills/freehire-search/cli/tests/request-timeout.test.ts b/.agents/skills/freehire-search/cli/tests/request-timeout.test.ts new file mode 100644 index 0000000..a0baebc --- /dev/null +++ b/.agents/skills/freehire-search/cli/tests/request-timeout.test.ts @@ -0,0 +1,26 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { apiGet } from "../src/helpers"; + +// A stalled upstream connection (accepted socket, no response) would otherwise +// hang the CLI forever - fetch has no default timeout. Assert the request +// wrapper carries an AbortSignal timeout. Fails on the pre-fix code (no signal). +const originalFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe("apiGet request timeout", () => { + test("passes an AbortSignal timeout to fetch", async () => { + let init: RequestInit | undefined; + globalThis.fetch = (async (_url: string | URL | Request, i?: RequestInit) => { + init = i; + return new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as unknown as typeof fetch; + + await apiGet("/jobs"); + expect(init?.signal).toBeInstanceOf(AbortSignal); + }); +}); diff --git a/.agents/skills/jobbank-search/cli/src/helpers.ts b/.agents/skills/jobbank-search/cli/src/helpers.ts index 3e1e862..528e5d4 100644 --- a/.agents/skills/jobbank-search/cli/src/helpers.ts +++ b/.agents/skills/jobbank-search/cli/src/helpers.ts @@ -15,6 +15,7 @@ export async function fetchWithUA(url: string): Promise { for (let attempt = 0; attempt <= maxRetries; attempt++) { const response = await fetch(url, { headers: { "User-Agent": USER_AGENT }, + signal: AbortSignal.timeout(15000), }) if (response.status === 429 || response.status >= 500) { if (attempt === maxRetries) { diff --git a/.agents/skills/jobbank-search/cli/tests/request-timeout.test.ts b/.agents/skills/jobbank-search/cli/tests/request-timeout.test.ts new file mode 100644 index 0000000..f60917c --- /dev/null +++ b/.agents/skills/jobbank-search/cli/tests/request-timeout.test.ts @@ -0,0 +1,23 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { fetchWithUA } from "../src/helpers"; + +// A stalled upstream connection (accepted socket, no response) would otherwise +// hang the CLI forever - fetch has no default timeout. Assert the request +// wrapper carries an AbortSignal timeout. Fails on the pre-fix code (no signal). +const originalFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe("fetchWithUA request timeout", () => { + test("passes an AbortSignal timeout to fetch", async () => { + let init: RequestInit | undefined; + globalThis.fetch = (async (_url: string | URL | Request, i?: RequestInit) => { + init = i; + return new Response("", { status: 200 }); + }) as unknown as typeof fetch; + + await fetchWithUA("https://jobbank.dk/job/rss"); + expect(init?.signal).toBeInstanceOf(AbortSignal); + }); +}); diff --git a/.agents/skills/jobdanmark-search/cli/src/commands/detail.ts b/.agents/skills/jobdanmark-search/cli/src/commands/detail.ts index 4d37f60..5a437eb 100644 --- a/.agents/skills/jobdanmark-search/cli/src/commands/detail.ts +++ b/.agents/skills/jobdanmark-search/cli/src/commands/detail.ts @@ -217,6 +217,7 @@ export const detail = defineCommand({ "Accept": "text/html,application/xhtml+xml", "User-Agent": "Mozilla/5.0", }, + signal: AbortSignal.timeout(15000), }) if (response.status === 404) { diff --git a/.agents/skills/jobdanmark-search/cli/src/helpers.ts b/.agents/skills/jobdanmark-search/cli/src/helpers.ts index 212bbc3..51e3299 100644 --- a/.agents/skills/jobdanmark-search/cli/src/helpers.ts +++ b/.agents/skills/jobdanmark-search/cli/src/helpers.ts @@ -10,7 +10,7 @@ export async function apiFetch(path: string, params?: Record) const maxRetries = 6 let delay = 500 for (let attempt = 0; attempt <= maxRetries; attempt++) { - const response = await fetch(url) + const response = await fetch(url, { signal: AbortSignal.timeout(15000) }) if (response.status === 429 || response.status >= 500) { if (attempt === maxRetries) { throw new Error(`API request failed: ${response.status} ${response.statusText}`) @@ -40,6 +40,7 @@ export async function apiPost(path: string, body: unknown): Promise { "Content-Type": "application/json", }, body: JSON.stringify(body), + signal: AbortSignal.timeout(15000), }) if (response.status === 429 || response.status >= 500) { if (attempt === maxRetries) { diff --git a/.agents/skills/jobdanmark-search/cli/tests/request-timeout.test.ts b/.agents/skills/jobdanmark-search/cli/tests/request-timeout.test.ts new file mode 100644 index 0000000..6de26a2 --- /dev/null +++ b/.agents/skills/jobdanmark-search/cli/tests/request-timeout.test.ts @@ -0,0 +1,34 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { apiFetch, apiPost } from "../src/helpers"; + +// A stalled upstream connection (accepted socket, no response) would otherwise +// hang the CLI forever - fetch has no default timeout. Assert both request +// wrappers carry an AbortSignal timeout. Fails on the pre-fix code (no signal). +const originalFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe("request timeout", () => { + test("apiFetch passes an AbortSignal timeout to fetch", async () => { + let init: RequestInit | undefined; + globalThis.fetch = (async (_url: string | URL | Request, i?: RequestInit) => { + init = i; + return new Response("{}", { status: 200 }); + }) as unknown as typeof fetch; + + await apiFetch("/search"); + expect(init?.signal).toBeInstanceOf(AbortSignal); + }); + + test("apiPost passes an AbortSignal timeout to fetch", async () => { + let init: RequestInit | undefined; + globalThis.fetch = (async (_url: string | URL | Request, i?: RequestInit) => { + init = i; + return new Response("{}", { status: 200 }); + }) as unknown as typeof fetch; + + await apiPost("/search", { q: "x" }); + expect(init?.signal).toBeInstanceOf(AbortSignal); + }); +}); diff --git a/.agents/skills/jobindex-search/cli/src/helpers.ts b/.agents/skills/jobindex-search/cli/src/helpers.ts index 97c6bce..48d940f 100644 --- a/.agents/skills/jobindex-search/cli/src/helpers.ts +++ b/.agents/skills/jobindex-search/cli/src/helpers.ts @@ -14,7 +14,7 @@ export async function apiFetch(path: string, params?: Record) const maxRetries = 6 let delay = 500 for (let attempt = 0; attempt <= maxRetries; attempt++) { - const response = await fetch(url) + const response = await fetch(url, { signal: AbortSignal.timeout(15000) }) if (response.status === 429 || response.status >= 500) { if (attempt === maxRetries) { throw new Error(`API request failed: ${response.status} ${response.statusText}`) @@ -43,6 +43,7 @@ export async function htmlFetch(url: string): Promise { "Accept-Language": "da,en;q=0.9", }, redirect: "follow", + signal: AbortSignal.timeout(15000), }) if (response.status === 429 || response.status >= 500) { if (attempt === maxRetries) { diff --git a/.agents/skills/jobindex-search/cli/tests/request-timeout.test.ts b/.agents/skills/jobindex-search/cli/tests/request-timeout.test.ts new file mode 100644 index 0000000..4d521f6 --- /dev/null +++ b/.agents/skills/jobindex-search/cli/tests/request-timeout.test.ts @@ -0,0 +1,34 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { apiFetch, htmlFetch } from "../src/helpers"; + +// A stalled upstream connection (accepted socket, no response) would otherwise +// hang the CLI forever - fetch has no default timeout. Assert both request +// wrappers carry an AbortSignal timeout. Fails on the pre-fix code (no signal). +const originalFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe("request timeout", () => { + test("apiFetch passes an AbortSignal timeout to fetch", async () => { + let init: RequestInit | undefined; + globalThis.fetch = (async (_url: string | URL | Request, i?: RequestInit) => { + init = i; + return new Response("{}", { status: 200 }); + }) as unknown as typeof fetch; + + await apiFetch("/search"); + expect(init?.signal).toBeInstanceOf(AbortSignal); + }); + + test("htmlFetch passes an AbortSignal timeout to fetch", async () => { + let init: RequestInit | undefined; + globalThis.fetch = (async (_url: string | URL | Request, i?: RequestInit) => { + init = i; + return new Response("", { status: 200 }); + }) as unknown as typeof fetch; + + await htmlFetch("https://www.jobindex.dk/x"); + expect(init?.signal).toBeInstanceOf(AbortSignal); + }); +}); diff --git a/.agents/skills/jobnet-search/cli/src/helpers.ts b/.agents/skills/jobnet-search/cli/src/helpers.ts index 866b3aa..8cf2016 100644 --- a/.agents/skills/jobnet-search/cli/src/helpers.ts +++ b/.agents/skills/jobnet-search/cli/src/helpers.ts @@ -14,6 +14,7 @@ export async function apiFetch(path: string, params?: Record) headers: { "x-csrf": "1", }, + signal: AbortSignal.timeout(15000), }) if (response.status === 429 || response.status >= 500) { diff --git a/.agents/skills/jobnet-search/cli/tests/request-timeout.test.ts b/.agents/skills/jobnet-search/cli/tests/request-timeout.test.ts new file mode 100644 index 0000000..5ded472 --- /dev/null +++ b/.agents/skills/jobnet-search/cli/tests/request-timeout.test.ts @@ -0,0 +1,23 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { apiFetch } from "../src/helpers"; + +// A stalled upstream connection (accepted socket, no response) would otherwise +// hang the CLI forever - fetch has no default timeout. Assert every request +// carries an AbortSignal timeout. This fails on the pre-fix code (no signal). +const originalFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe("apiFetch request timeout", () => { + test("passes an AbortSignal timeout to fetch", async () => { + let init: RequestInit | undefined; + globalThis.fetch = (async (_url: string | URL | Request, i?: RequestInit) => { + init = i; + return new Response("{}", { status: 200 }); + }) as unknown as typeof fetch; + + await apiFetch("/search"); + expect(init?.signal).toBeInstanceOf(AbortSignal); + }); +}); diff --git a/.agents/skills/linkedin-search/cli/src/helpers.ts b/.agents/skills/linkedin-search/cli/src/helpers.ts index 19e0200..32d7bf8 100644 --- a/.agents/skills/linkedin-search/cli/src/helpers.ts +++ b/.agents/skills/linkedin-search/cli/src/helpers.ts @@ -29,6 +29,7 @@ export async function htmlFetch(url: string): Promise { "X-Requested-With": "XMLHttpRequest", }, redirect: "follow", + signal: AbortSignal.timeout(15000), }) if (response.status === 429 || response.status >= 500) { if (attempt === maxRetries) { diff --git a/.agents/skills/linkedin-search/cli/tests/request-timeout.test.ts b/.agents/skills/linkedin-search/cli/tests/request-timeout.test.ts new file mode 100644 index 0000000..48161d3 --- /dev/null +++ b/.agents/skills/linkedin-search/cli/tests/request-timeout.test.ts @@ -0,0 +1,23 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { htmlFetch } from "../src/helpers"; + +// A stalled upstream connection (accepted socket, no response) would otherwise +// hang the CLI forever - fetch has no default timeout. Assert the request +// wrapper carries an AbortSignal timeout. Fails on the pre-fix code (no signal). +const originalFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe("htmlFetch request timeout", () => { + test("passes an AbortSignal timeout to fetch", async () => { + let init: RequestInit | undefined; + globalThis.fetch = (async (_url: string | URL | Request, i?: RequestInit) => { + init = i; + return new Response("", { status: 200 }); + }) as unknown as typeof fetch; + + await htmlFetch("https://www.linkedin.com/jobs-guest/jobs/api/x"); + expect(init?.signal).toBeInstanceOf(AbortSignal); + }); +});