Skip to content

Commit fa70f7e

Browse files
committed
try a fallback ua for blocking hosts; #140
1 parent c74ed70 commit fa70f7e

2 files changed

Lines changed: 187 additions & 12 deletions

File tree

feed-proxy/src/app.ts

Lines changed: 66 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -115,11 +115,18 @@ type DiscoverResult =
115115
| { kind: 'blocked'; error: string }
116116
| { kind: 'error'; error: string };
117117

118+
// Our honest fetcher identity, used for the first attempt of every upstream fetch.
119+
// We don't impersonate Googlebot ("like FeedFetcher-Google"): CDNs such as Akamai
120+
// (used by cbc.ca) verify Google crawlers by reverse-DNS and 403 any non-Google IP
121+
// that claims to be one. But the honest UA is itself a problem on those same CDNs —
122+
// their bot managers gate on the UA's *shape* (a parenthetical comment, an embedded
123+
// URL, or the token "bot" all read as non-browser), so this string is frequently
124+
// blocked. When it's refused we transparently retry with BROWSER_FALLBACK_UA; see
125+
// fetchWithBotFallback.
126+
const HONEST_UA = 'Skyreader/1.0 (+https://skyreader.app)';
127+
118128
const FETCH_HEADERS = {
119-
// Don't impersonate Googlebot (e.g. "like FeedFetcher-Google"): CDNs such as
120-
// Akamai (used by cbc.ca) verify Google crawlers by reverse-DNS and 403 any
121-
// non-Google IP that claims to be one. Identify honestly with a contact URL.
122-
'User-Agent': 'Skyreader/1.0 (+https://skyreader.app)',
129+
'User-Agent': HONEST_UA,
123130
Accept: 'application/rss+xml, application/atom+xml, application/xml, text/xml, */*',
124131
'Accept-Language': 'en-US,en;q=0.9',
125132
'Accept-Encoding': 'gzip, deflate',
@@ -191,6 +198,59 @@ const MAX_RESPONSE_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB
191198
// time so repeat (and cross-user) saves of the same article are free.
192199
const EXTRACT_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
193200

201+
// A real desktop-Chrome User-Agent, used only as a fallback when HONEST_UA is
202+
// refused. Verified against cbc.ca (Akamai): a browser UA returns 200 straight
203+
// through Bun's fetch, so it's the UA string alone — not the TLS/JA3 fingerprint —
204+
// that these CDNs gate on, which is why swapping just this header is sufficient.
205+
const BROWSER_FALLBACK_UA =
206+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36';
207+
208+
// First-attempt timeout for the honest-UA probe. Hostile CDNs often don't 403 —
209+
// they silently black-hole the connection so the fetch hangs to the full timeout.
210+
// Capping the honest probe well below FETCH_TIMEOUT_MS means a black-hole costs
211+
// ~this long, not 30s, before we retry with a browser UA. Kept comfortably above a
212+
// healthy feed's latency so normal-but-slow feeds aren't needlessly retried.
213+
const HONEST_UA_PROBE_TIMEOUT_MS = 10 * 1000; // 10 seconds
214+
215+
// Fetch an upstream URL identifying honestly (HONEST_UA), transparently retrying
216+
// once with a browser UA when the honest attempt is blocked — either an explicit
217+
// 403 or a silent black-hole (timeout / connection error on the short probe).
218+
// `headers` carries the caller's headers (incl. HONEST_UA and any conditional-
219+
// request headers); only the User-Agent and per-attempt timeout differ between the
220+
// two attempts. Returns the final Response unchanged for the caller to classify; if
221+
// the browser-UA attempt also fails, its error propagates (and is classified as a
222+
// network/timeout error exactly as before this fallback existed).
223+
export async function fetchWithBotFallback(
224+
url: string,
225+
headers: Record<string, string>,
226+
opts: { probeTimeoutMs?: number; fetchTimeoutMs?: number } = {}
227+
): Promise<Response> {
228+
const probeTimeoutMs = opts.probeTimeoutMs ?? HONEST_UA_PROBE_TIMEOUT_MS;
229+
const fetchTimeoutMs = opts.fetchTimeoutMs ?? FETCH_TIMEOUT_MS;
230+
231+
// Attempt 1: honest UA, short timeout.
232+
try {
233+
const res = await safeFetch(url, {
234+
headers,
235+
signal: AbortSignal.timeout(probeTimeoutMs),
236+
});
237+
// Only a 403 is treated as a block worth retrying; every other status
238+
// (200/304/404/5xx) is a real answer the caller should handle as-is.
239+
if (!isBlockedStatus(res.status)) return res;
240+
await res.body?.cancel().catch(() => {});
241+
} catch {
242+
// Timeout / connection error on the honest attempt: possibly a silent block,
243+
// possibly a genuinely slow or down feed — indistinguishable here, so fall
244+
// through to the browser-UA attempt and let it settle the outcome.
245+
}
246+
247+
// Attempt 2: browser UA, full timeout.
248+
return safeFetch(url, {
249+
headers: { ...headers, 'User-Agent': BROWSER_FALLBACK_UA },
250+
signal: AbortSignal.timeout(fetchTimeoutMs),
251+
});
252+
}
253+
194254
export interface ExtractedArticle {
195255
title: string | null;
196256
author: string | null;
@@ -714,10 +774,7 @@ export function initDatabase(db: Database): void {
714774
// an HTTP response. Never throws — failures return an 'error'/'blocked' kind.
715775
async function runDiscover(siteUrl: string): Promise<DiscoverResult> {
716776
try {
717-
const response = await safeFetch(siteUrl, {
718-
headers: FETCH_HEADERS,
719-
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
720-
});
777+
const response = await fetchWithBotFallback(siteUrl, FETCH_HEADERS);
721778

722779
if (!response.ok) {
723780
const { error, blocked } = describeFetchFailure(response.status, siteUrl);
@@ -914,10 +971,7 @@ export function createApp(db: Database, config: AppConfig) {
914971
if (cached?.last_modified) headers['If-Modified-Since'] = cached.last_modified;
915972

916973
try {
917-
const response = await safeFetch(url, {
918-
headers,
919-
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
920-
});
974+
const response = await fetchWithBotFallback(url, headers);
921975

922976
if (response.status === 304 && cached) {
923977
// Success: reset error tracking
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { describe, expect, it, afterEach, spyOn } from 'bun:test';
2+
import { fetchWithBotFallback } from './app';
3+
4+
// The honest identity fetchWithBotFallback sends on its first attempt (mirrors
5+
// HONEST_UA in app.ts; kept as a literal here so the test pins the actual string).
6+
const HONEST_UA = 'Skyreader/1.0 (+https://skyreader.app)';
7+
const URL = 'https://blocked.example.com/feed';
8+
9+
function uaOf(init: unknown): string {
10+
return (
11+
((init as RequestInit | undefined)?.headers as Record<string, string>)?.['User-Agent'] ?? ''
12+
);
13+
}
14+
function headersOf(init: unknown): Record<string, string> {
15+
return ((init as RequestInit | undefined)?.headers ?? {}) as Record<string, string>;
16+
}
17+
18+
describe('fetchWithBotFallback', () => {
19+
let fetchMock: ReturnType<typeof spyOn>;
20+
afterEach(() => fetchMock?.mockRestore());
21+
22+
it('uses the honest UA and does not retry when the first attempt succeeds', async () => {
23+
const seen: string[] = [];
24+
fetchMock = spyOn(globalThis, 'fetch').mockImplementation((async (
25+
_url: unknown,
26+
init: unknown
27+
) => {
28+
seen.push(uaOf(init));
29+
return new Response('<rss/>', { status: 200 });
30+
}) as unknown as typeof fetch);
31+
32+
const res = await fetchWithBotFallback(URL, { 'User-Agent': HONEST_UA });
33+
expect(res.status).toBe(200);
34+
expect(seen).toEqual([HONEST_UA]);
35+
});
36+
37+
it('does NOT retry on a non-403 error status (e.g. 404/500)', async () => {
38+
const seen: string[] = [];
39+
fetchMock = spyOn(globalThis, 'fetch').mockImplementation((async (
40+
_url: unknown,
41+
init: unknown
42+
) => {
43+
seen.push(uaOf(init));
44+
return new Response('not found', { status: 404 });
45+
}) as unknown as typeof fetch);
46+
47+
const res = await fetchWithBotFallback(URL, { 'User-Agent': HONEST_UA });
48+
expect(res.status).toBe(404);
49+
expect(seen).toEqual([HONEST_UA]); // single attempt — only 403 is a "block"
50+
});
51+
52+
it('retries with a browser UA after an explicit 403', async () => {
53+
const seen: string[] = [];
54+
fetchMock = spyOn(globalThis, 'fetch').mockImplementation((async (
55+
_url: unknown,
56+
init: unknown
57+
) => {
58+
const ua = uaOf(init);
59+
seen.push(ua);
60+
return ua === HONEST_UA
61+
? new Response('blocked', { status: 403 })
62+
: new Response('<rss/>', { status: 200 });
63+
}) as unknown as typeof fetch);
64+
65+
const res = await fetchWithBotFallback(URL, { 'User-Agent': HONEST_UA });
66+
expect(res.status).toBe(200);
67+
expect(seen.length).toBe(2);
68+
expect(seen[0]).toBe(HONEST_UA);
69+
expect(seen[1]).toContain('Mozilla/5.0');
70+
expect(seen[1]).toContain('Chrome');
71+
});
72+
73+
it('retries with a browser UA when the honest attempt silently hangs (timeout)', async () => {
74+
const seen: string[] = [];
75+
fetchMock = spyOn(globalThis, 'fetch').mockImplementation((async (
76+
_url: unknown,
77+
init: unknown
78+
) => {
79+
const ua = uaOf(init);
80+
seen.push(ua);
81+
if (ua === HONEST_UA) {
82+
// Simulate a black-holed connection: never resolve, reject only on abort
83+
// (which AbortSignal.timeout(probeTimeoutMs) triggers).
84+
const signal = (init as RequestInit).signal!;
85+
return new Promise<Response>((_resolve, reject) => {
86+
signal.addEventListener('abort', () =>
87+
reject(new DOMException('The operation timed out.', 'TimeoutError'))
88+
);
89+
});
90+
}
91+
return new Response('<rss/>', { status: 200 });
92+
}) as unknown as typeof fetch);
93+
94+
const res = await fetchWithBotFallback(
95+
URL,
96+
{ 'User-Agent': HONEST_UA },
97+
{ probeTimeoutMs: 50 }
98+
);
99+
expect(res.status).toBe(200);
100+
expect(seen.length).toBe(2);
101+
expect(seen[1]).toContain('Mozilla/5.0');
102+
});
103+
104+
it('preserves conditional-request headers across the fallback, swapping only the UA', async () => {
105+
const seen: Array<Record<string, string>> = [];
106+
fetchMock = spyOn(globalThis, 'fetch').mockImplementation((async (
107+
_url: unknown,
108+
init: unknown
109+
) => {
110+
const h = headersOf(init);
111+
seen.push(h);
112+
return h['User-Agent'] === HONEST_UA
113+
? new Response('blocked', { status: 403 })
114+
: new Response('<rss/>', { status: 200 });
115+
}) as unknown as typeof fetch);
116+
117+
await fetchWithBotFallback(URL, { 'User-Agent': HONEST_UA, 'If-None-Match': '"abc"' });
118+
expect(seen[1]['If-None-Match']).toBe('"abc"');
119+
expect(seen[1]['User-Agent']).toContain('Mozilla/5.0');
120+
});
121+
});

0 commit comments

Comments
 (0)