diff --git a/src/daemon/rest/errors.ts b/src/daemon/rest/errors.ts index 50822bb02..dcfb29ea3 100644 --- a/src/daemon/rest/errors.ts +++ b/src/daemon/rest/errors.ts @@ -125,6 +125,14 @@ const SEMANTIC_VALIDATION_REASONS = new Set([ 'unsupported_scheme', ]); +/** Upstream HTTP failures are coded `http_` (see tools/fetch.ts) → 502. */ +const HTTP_STATUS_CODE = /^http_\d{3}$/; + +/** + * A tool failure. NOTE the field convention here is the inverse of + * `ErrorEnvelope` above: on a StageResult `error` is the machine code and + * `error_reason` is the human sentence. + */ export interface StageFailure { error: string; error_reason: string; @@ -134,12 +142,13 @@ export interface StageFailure { /** * Map a StageResult failure to an HTTP status. Conservative + table-driven: * 503 for known unavailability, 502 for fetch-stage upstream failures, 400 for - * the explicit semantic-validation allowlist, else 500. Never substring-scans. + * the explicit semantic-validation allowlist, else 500. Keyed on the machine + * code (`error`), never a substring scan of the `error_reason` sentence. */ export function statusForStageResult(f: StageFailure): number { - if (UNAVAILABILITY_REASONS.has(f.error_reason)) return 503; - if (f.stage === 'fetch' && FETCH_UPSTREAM_REASONS.has(f.error_reason)) return 502; - if (SEMANTIC_VALIDATION_REASONS.has(f.error_reason)) return 400; + if (UNAVAILABILITY_REASONS.has(f.error)) return 503; + if (f.stage === 'fetch' && (FETCH_UPSTREAM_REASONS.has(f.error) || HTTP_STATUS_CODE.test(f.error))) return 502; + if (SEMANTIC_VALIDATION_REASONS.has(f.error)) return 400; return 500; } diff --git a/tests/unit/daemon/rest-dispatch.test.ts b/tests/unit/daemon/rest-dispatch.test.ts index 174d763ba..9c5d3989f 100644 --- a/tests/unit/daemon/rest-dispatch.test.ts +++ b/tests/unit/daemon/rest-dispatch.test.ts @@ -57,13 +57,22 @@ describe('dispatchTool — fetch', () => { it('failure maps via errors.ts status table (fetch upstream → 502)', async () => { vi.mocked(handleFetch).mockResolvedValue({ - ok: false, error: 'blocked', error_reason: 'blocked_by_challenge', stage: 'fetch', + ok: false, error: 'blocked_by_challenge', error_reason: 'the site returned a bot challenge', stage: 'fetch', } as never); const r = await dispatchTool('fetch', { url: 'https://x.com' }, fakeCtx()); expect(r.status).toBe(502); expect((r.body as { ok: boolean }).ok).toBe(false); }); + it('invalid input from the tool maps to 400, not 500', async () => { + vi.mocked(handleFetch).mockResolvedValue({ + ok: false, error: 'invalid_url', error_reason: 'url is not a valid absolute URL', stage: 'fetch', + } as never); + const r = await dispatchTool('fetch', { url: 'not a url' }, fakeCtx()); + expect(r.status).toBe(400); + expect((r.body as { ok: boolean }).ok).toBe(false); + }); + it('applies the serve-mode target guard before dispatch (non-loopback bind, loopback target → 400)', async () => { const ctx = fakeCtx(); ctx.bindIsLoopback = false; diff --git a/tests/unit/daemon/rest-errors.test.ts b/tests/unit/daemon/rest-errors.test.ts index c51e2330c..0012bf110 100644 --- a/tests/unit/daemon/rest-errors.test.ts +++ b/tests/unit/daemon/rest-errors.test.ts @@ -86,24 +86,41 @@ describe('error envelope builders', () => { }); }); +// On a StageResult the machine code lives in `error` and the human sentence in +// `error_reason` — the inverse of the REST envelope. Every case below is shaped +// the way src/tools/*.ts actually emits failures. describe('statusForStageResult', () => { it('unavailability code → 503', () => { - expect(statusForStageResult({ error: 'x', error_reason: 'browser_engine_unavailable', stage: 'fetch' })).toBe(503); + expect(statusForStageResult({ error: 'browser_engine_unavailable', error_reason: 'playwright is not installed', stage: 'fetch' })).toBe(503); }); it('fetch-stage upstream code → 502', () => { - expect(statusForStageResult({ error: 'x', error_reason: 'blocked_by_challenge', stage: 'fetch' })).toBe(502); - expect(statusForStageResult({ error: 'x', error_reason: 'fetch_failed', stage: 'fetch' })).toBe(502); + expect(statusForStageResult({ error: 'blocked_by_challenge', error_reason: 'the site returned a bot challenge', stage: 'fetch' })).toBe(502); + expect(statusForStageResult({ error: 'fetch_failed', error_reason: 'connection refused', stage: 'fetch' })).toBe(502); + }); + it('fetch-stage http_ code → 502', () => { + expect(statusForStageResult({ error: 'http_404', error_reason: 'Upstream returned HTTP 404', stage: 'fetch' })).toBe(502); + expect(statusForStageResult({ error: 'http_503', error_reason: 'Upstream returned HTTP 503', stage: 'fetch' })).toBe(502); }); it('semantic-validation allowlist → 400', () => { - expect(statusForStageResult({ error: 'x', error_reason: 'invalid_url', stage: 'validate' })).toBe(400); + expect(statusForStageResult({ error: 'invalid_url', error_reason: 'url is not a valid absolute URL', stage: 'fetch' })).toBe(400); }); - it('unknown reason → 500', () => { - expect(statusForStageResult({ error: 'x', error_reason: 'some_novel_reason', stage: 'extract' })).toBe(500); + it('unknown code → 500', () => { + expect(statusForStageResult({ error: 'some_novel_reason', error_reason: 'something new broke', stage: 'extract' })).toBe(500); }); - it('NEGATIVE: a reason containing the word "timeout" does NOT map to 504', () => { - expect(statusForStageResult({ error: 'connection timeout occurred', error_reason: 'network_timeout', stage: 'fetch' })).not.toBe(504); + it('NEGATIVE: a reason sentence containing the word "timeout" does NOT map to 504', () => { + expect(statusForStageResult({ error: 'network_timeout', error_reason: 'connection timeout occurred', stage: 'fetch' })).not.toBe(504); // network_timeout is not in the fetch upstream allowlist nor unavailability → 500 - expect(statusForStageResult({ error: 'connection timeout occurred', error_reason: 'network_timeout', stage: 'fetch' })).toBe(500); + expect(statusForStageResult({ error: 'network_timeout', error_reason: 'connection timeout occurred', stage: 'fetch' })).toBe(500); + }); + it('NEGATIVE: a code that only appears in the reason sentence is NOT matched', () => { + expect(statusForStageResult({ error: 'x', error_reason: 'invalid_url', stage: 'fetch' })).toBe(500); + expect(statusForStageResult({ error: 'x', error_reason: 'browser_engine_unavailable', stage: 'fetch' })).toBe(500); + }); + it('NEGATIVE: an http_-prefixed free-text reason is not a status code', () => { + expect(statusForStageResult({ error: 'http_gateway_wobble', error_reason: 'upstream misbehaved', stage: 'fetch' })).toBe(500); + }); + it('upstream codes only map to 502 on the fetch stage', () => { + expect(statusForStageResult({ error: 'fetch_failed', error_reason: 'connection refused', stage: 'extract' })).toBe(500); }); });