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`,