diff --git a/integration/errors.test.ts b/integration/errors.test.ts index d53edda..8053fbb 100644 --- a/integration/errors.test.ts +++ b/integration/errors.test.ts @@ -21,6 +21,89 @@ async function placesAnswering(...answers: Answer[]) { } describe("error classes", () => { + it.each([ + { method: "POST", status: 403 }, + { method: "POST", status: 401 }, + { method: "GET", status: 401 }, + { method: "GET", status: 404 }, + { method: "GET", status: 429 }, + { method: "GET", status: 503 }, + ])("reports a $method $status edge challenge without login, expiry or retry advice", async ({ + method, + status, + }) => { + signIn(s.home); + const requests: { method: string; path: string; token: string | null }[] = + []; + const edge = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(req) { + requests.push({ + method: req.method, + path: new URL(req.url).pathname, + token: req.headers.get("authorization"), + }); + return new Response("Just a moment...", { + status, + headers: { + "content-type": "text/html", + "cf-mitigated": "challenge", + "cf-ray": "a4d0830ba9d11e2d-SIN", + "retry-after": "0", + }, + }); + }, + }); + try { + for (let attempt = 0; attempt < 2; attempt++) { + const result = await s.run( + method === "POST" + ? [ + "hotels", + "search", + "AUH", + "2027-01-17", + "2027-01-22", + "--adults", + "2", + "--children", + "1", + "--children-ages", + "10", + "--rooms", + "1", + ] + : ["places", "London"], + { + env: { + WEGO_API_URL: String(edge.url), + WEGO_AUTH_TOKEN_URL: new URL("/oauth/token", edge.url).href, + }, + }, + ); + expect(result.code).toBe(6); + expect(result.out).toBe(""); + expect(result.err).toContain("Cloudflare challenged"); + expect(result.err).toContain("cf_ray=a4d0830ba9d11e2d-SIN"); + expect(result.err).toContain("feedback"); + expect(result.err).not.toContain("wego login"); + expect(result.err).not.toContain("trace_id="); + expect(result.err).not.toContain(""); + expect(result.err).not.toContain("expired"); + } + expect(requests).toEqual( + Array.from({ length: 2 }, () => ({ + method, + path: method === "POST" ? "/v1/hotels/searches" : "/v1/places", + token: "Bearer access-1", + })), + ); + } finally { + edge.stop(true); + } + }); + it("a persistent 401 with no refresh token exits 3 and names login", async () => { signIn(s.home, { accessToken: "access-revoked" }); s.fake({ accept: [] }); @@ -49,12 +132,16 @@ describe("error classes", () => { it("a 400 exits 6 and prints the API's detail and trace id", async () => { const { result } = await placesAnswering( - problem(400, "validation_failed", "query is too short"), + problem(400, "validation_failed", "query is too short", { + "cf-ray": "api-ray-SIN", + }), ); expect(result.code).toBe(6); expect(result.out).toBe(""); expect(result.err).toContain("query is too short"); expect(result.err).toContain("trace-400"); + expect(result.err).toContain("cf_ray=api-ray-SIN"); + expect(result.err).not.toContain("Cloudflare challenged"); }); it("a 429 is retried after Retry-After, then succeeds", async () => { diff --git a/src/api.test.ts b/src/api.test.ts index 914e9f6..824e68b 100644 --- a/src/api.test.ts +++ b/src/api.test.ts @@ -58,6 +58,67 @@ afterAll(() => { globalThis.fetch = realFetch; }); +describe("edge error metadata", () => { + it.each( + [401, 403, 404, 429, 503].map((status) => ({ status })), + )("identifies marked $status challenges without retrying and keeps edge metadata", async ({ + status, + }) => { + for (const ray of [undefined, "a4d0830ba9d11e2d-SIN"]) { + let calls = 0; + const http = (async () => { + calls++; + return new Response("Just a moment...", { + status, + headers: { + "content-type": "text/html", + "cf-mitigated": "challenge", + "retry-after": "0", + ...(ray ? { "cf-ray": ray } : {}), + }, + }); + }) as HttpFetch; + const err = await fetchWhoami( + "https://api.wego.com", + "tok", + undefined, + http, + ).catch((e) => e); + expect(err).toBeInstanceOf(ApiHttpError); + expect(err.edgeChallenge).toBe(true); + expect(err.rayId).toBe(ray); + expect(err.traceId).toBeUndefined(); + expect(err.code).toBeUndefined(); + expect(calls).toBe(1); + } + }); + + it("does not classify ordinary API errors behind Cloudflare as challenges", async () => { + const http = (async () => + new Response( + JSON.stringify({ code: "insufficient_scope", trace_id: "body-trace" }), + { + status: 403, + headers: { + "content-type": "application/problem+json", + "cf-ray": "ordinary-ray", + "x-trace-id": "api-trace", + }, + }, + )) as HttpFetch; + const err = await fetchWhoami( + "https://api.wego.com", + "tok", + undefined, + http, + ).catch((e) => e); + expect(err.edgeChallenge).toBe(false); + expect(err.rayId).toBe("ordinary-ray"); + expect(err.traceId).toBe("api-trace"); + expect(err.code).toBe("insufficient_scope"); + }); +}); + describe("USER_AGENT", () => { it("is the Wego-CLI product token, so apps/api can classify the caller", () => { expect(USER_AGENT).toMatch(/^Wego-CLI\/\S+$/); diff --git a/src/api.ts b/src/api.ts index 0f0dcf8..e85aae4 100644 --- a/src/api.ts +++ b/src/api.ts @@ -96,6 +96,8 @@ export interface ApiHttpErrorFields { code?: string; detail?: string; traceId?: string; + edgeChallenge?: boolean; + rayId?: string; retryAfterSeconds?: number; bodyParseError?: boolean; } @@ -115,6 +117,8 @@ export class ApiHttpError extends Error { readonly code?: string; readonly detail?: string; readonly traceId?: string; + readonly edgeChallenge: boolean; + readonly rayId?: string; readonly retryAfterSeconds?: number; readonly bodyParseError: boolean; @@ -130,6 +134,8 @@ export class ApiHttpError extends Error { this.code = fields.code; this.detail = fields.detail; this.traceId = fields.traceId; + this.edgeChallenge = fields.edgeChallenge ?? false; + this.rayId = fields.rayId; this.retryAfterSeconds = fields.retryAfterSeconds; this.bodyParseError = fields.bodyParseError ?? false; } @@ -195,6 +201,8 @@ async function readApiError( code: parsed.code, detail: parsed.detail, traceId: res.headers.get("x-trace-id") ?? parsed.traceId, + edgeChallenge: res.headers.get("cf-mitigated") === "challenge", + rayId: res.headers.get("cf-ray") ?? undefined, retryAfterSeconds: parseRetryAfter(res.headers.get("retry-after")), bodyParseError: parsed.bodyParseError, }); @@ -355,6 +363,9 @@ async function authedJsonGet( }, http, ); + if (res.headers.get("cf-mitigated") === "challenge") { + throw await readApiError(res, label); + } if (res.status === 401) throw new UnauthorizedError(); if (res.status === 404) throw new NotFoundError(label); if (!res.ok) { @@ -409,6 +420,9 @@ async function authedJsonPost( }, http, ); + if (res.headers.get("cf-mitigated") === "challenge") { + throw await readApiError(res, label); + } if (res.status === 401) throw new UnauthorizedError(); if (!res.ok) { // No auto-retry on POST: a create is not idempotent, so a blind retry could diff --git a/src/error-report.ts b/src/error-report.ts index 5c53c26..97477d8 100644 --- a/src/error-report.ts +++ b/src/error-report.ts @@ -80,6 +80,7 @@ const EXIT_BY_PROBLEM_CODE = new Map( * binary was compiled before. Split out of `exitCodeForError` to keep that * dispatcher within the cognitive-complexity gate. */ function exitCodeForHttpError(err: ApiHttpError): number { + if (err.edgeChallenge) return EXIT.PERMANENT; const byCode = err.code === undefined ? undefined : EXIT_BY_PROBLEM_CODE.get(err.code); if (byCode !== undefined) return byCode; @@ -116,6 +117,7 @@ function formatHttpError(err: ApiHttpError, prog: string): string { const parts = [err.message]; if (err.bodyParseError) parts.push("(unparseable error body)"); if (err.traceId) parts.push(`trace_id=${err.traceId}`); + if (err.rayId) parts.push(`cf_ray=${err.rayId}`); if (err.retryAfterSeconds != null) { parts.push(`retry after ${err.retryAfterSeconds}s`); } @@ -156,6 +158,11 @@ function rateLimitRemedy(err: ApiHttpError, prog: string): string[] { * the remedy is already right. */ function authRemedy(err: ApiHttpError, prog: string): string[] { + if (err.edgeChallenge) { + return [ + `Cloudflare challenged this request before it reached the API; logging in again will not solve the challenge. Report this with \`${prog} feedback --message "..."\` and include the cf_ray if shown`, + ]; + } if (err.code === "insufficient_scope") { return [ `not authorized for this operation – \`${prog} login\` would re-request the same scopes, so ask for this access to be granted to your Wego account`,