From 6e52b7ee6fd28742426574ae06daeafccd27ac1e Mon Sep 17 00:00:00 2001 From: Chu Yeow Date: Tue, 20 Oct 2026 02:44:36 +0000 Subject: [PATCH 1/2] fix(cli): diagnose Cloudflare challenges without login advice Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- integration/errors.test.ts | 70 +++++++++++++++++++++++++++++++++++++- src/api.test.ts | 52 ++++++++++++++++++++++++++++ src/api.ts | 8 +++++ src/error-report.ts | 7 ++++ 4 files changed, 136 insertions(+), 1 deletion(-) diff --git a/integration/errors.test.ts b/integration/errors.test.ts index d53edda..7ed09a2 100644 --- a/integration/errors.test.ts +++ b/integration/errors.test.ts @@ -21,6 +21,70 @@ async function placesAnswering(...answers: Answer[]) { } describe("error classes", () => { + it("reports a hotel-search edge challenge without asking a signed-in caller to log in", async () => { + 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: 403, + headers: { + "content-type": "text/html", + "cf-mitigated": "challenge", + "cf-ray": "a4d0830ba9d11e2d-SIN", + }, + }); + }, + }); + try { + for (let attempt = 0; attempt < 2; attempt++) { + const result = await s.run( + [ + "hotels", + "search", + "AUH", + "2027-01-17", + "2027-01-22", + "--adults", + "2", + "--children", + "1", + "--children-ages", + "10", + "--rooms", + "1", + ], + { env: { WEGO_API_URL: String(edge.url) } }, + ); + 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(requests).toEqual( + Array.from({ length: 2 }, () => ({ + method: "POST", + path: "/v1/hotels/searches", + 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 +113,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..15e733c 100644 --- a/src/api.test.ts +++ b/src/api.test.ts @@ -58,6 +58,58 @@ afterAll(() => { globalThis.fetch = realFetch; }); +describe("edge error metadata", () => { + it("identifies challenge responses and keeps the edge ray separate from API traces", async () => { + for (const ray of [undefined, "a4d0830ba9d11e2d-SIN"]) { + const http = (async () => + new Response("Just a moment...", { + status: 403, + headers: { + "content-type": "text/html", + "cf-mitigated": "challenge", + ...(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(); + } + }); + + 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..826f2e2 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, }); 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`, From f7b614884efb261b6bc3bbbbd99c02ebbaaa5bf9 Mon Sep 17 00:00:00 2001 From: Chu Yeow Date: Tue, 20 Oct 2026 04:09:51 +0000 Subject: [PATCH 2/2] fix(api): prioritize edge challenges over status shortcuts Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- integration/errors.test.ts | 59 +++++++++++++++++++++++++------------- src/api.test.ts | 19 ++++++++---- src/api.ts | 6 ++++ 3 files changed, 59 insertions(+), 25 deletions(-) diff --git a/integration/errors.test.ts b/integration/errors.test.ts index 7ed09a2..8053fbb 100644 --- a/integration/errors.test.ts +++ b/integration/errors.test.ts @@ -21,7 +21,17 @@ async function placesAnswering(...answers: Answer[]) { } describe("error classes", () => { - it("reports a hotel-search edge challenge without asking a signed-in caller to log in", async () => { + 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 }[] = []; @@ -35,11 +45,12 @@ describe("error classes", () => { token: req.headers.get("authorization"), }); return new Response("Just a moment...", { - status: 403, + status, headers: { "content-type": "text/html", "cf-mitigated": "challenge", "cf-ray": "a4d0830ba9d11e2d-SIN", + "retry-after": "0", }, }); }, @@ -47,22 +58,29 @@ describe("error classes", () => { try { for (let attempt = 0; attempt < 2; attempt++) { const result = await s.run( - [ - "hotels", - "search", - "AUH", - "2027-01-17", - "2027-01-22", - "--adults", - "2", - "--children", - "1", - "--children-ages", - "10", - "--rooms", - "1", - ], - { env: { WEGO_API_URL: String(edge.url) } }, + 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(""); @@ -72,11 +90,12 @@ describe("error classes", () => { 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: "POST", - path: "/v1/hotels/searches", + method, + path: method === "POST" ? "/v1/hotels/searches" : "/v1/places", token: "Bearer access-1", })), ); diff --git a/src/api.test.ts b/src/api.test.ts index 15e733c..824e68b 100644 --- a/src/api.test.ts +++ b/src/api.test.ts @@ -59,17 +59,25 @@ afterAll(() => { }); describe("edge error metadata", () => { - it("identifies challenge responses and keeps the edge ray separate from API traces", async () => { + 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"]) { - const http = (async () => - new Response("Just a moment...", { - status: 403, + 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; + }); + }) as HttpFetch; const err = await fetchWhoami( "https://api.wego.com", "tok", @@ -81,6 +89,7 @@ describe("edge error metadata", () => { expect(err.rayId).toBe(ray); expect(err.traceId).toBeUndefined(); expect(err.code).toBeUndefined(); + expect(calls).toBe(1); } }); diff --git a/src/api.ts b/src/api.ts index 826f2e2..e85aae4 100644 --- a/src/api.ts +++ b/src/api.ts @@ -363,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) { @@ -417,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