diff --git a/src/shared/components/OAuthModal.js b/src/shared/components/OAuthModal.js index 751bc065ff2..1f89a0a5ccb 100644 --- a/src/shared/components/OAuthModal.js +++ b/src/shared/components/OAuthModal.js @@ -4,6 +4,7 @@ import { useState, useEffect, useRef, useCallback } from "react"; import PropTypes from "prop-types"; import { Modal, Button, Input } from "@/shared/components"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; +import { parseResponseBody, getResponseErrorMessage } from "@/shared/utils/api"; /** * OAuth Modal Component @@ -55,8 +56,8 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, }), }); - const data = await res.json(); - if (!res.ok) throw new Error(data.error); + const data = await parseResponseBody(res); + if (!res.ok) throw new Error(getResponseErrorMessage(res, data, "OAuth exchange failed")); setStep("success"); onSuccess?.(); @@ -74,8 +75,8 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code, state: authData.state }), }); - const data = await res.json(); - if (!res.ok) throw new Error(data.error); + const data = await parseResponseBody(res); + if (!res.ok) throw new Error(getResponseErrorMessage(res, data, "xAI manual code exchange failed")); setStep("success"); onSuccess?.(); @@ -115,7 +116,8 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, body: JSON.stringify({ deviceCode, codeVerifier, extraData }), }); - const data = await res.json(); + const data = await parseResponseBody(res); + if (!res.ok) throw new Error(getResponseErrorMessage(res, data, "OAuth polling failed")); if (data.success) { pollingAbortRef.current = true; // Stop polling immediately @@ -166,8 +168,8 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, deviceCodeUrl.searchParams.set("auth_method", "idc"); } const res = await fetch(deviceCodeUrl.toString()); - const data = await res.json(); - if (!res.ok) throw new Error(data.error); + const data = await parseResponseBody(res); + if (!res.ok) throw new Error(getResponseErrorMessage(res, data, "OAuth device-code request failed")); setDeviceData(data); @@ -207,8 +209,8 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, Object.entries(oauthMeta).forEach(([k, v]) => { if (v) authorizeUrl.searchParams.set(k, v); }); } const res = await fetch(authorizeUrl.toString()); - const data = await res.json(); - if (!res.ok) throw new Error(data.error); + const data = await parseResponseBody(res); + if (!res.ok) throw new Error(getResponseErrorMessage(res, data, "OAuth authorization failed")); // Codex: start proxy with server-side session (auto-exchange) + fallback to channels let codexProxyActive = false; @@ -221,7 +223,8 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, proxyUrl.searchParams.set("code_verifier", data.codeVerifier); proxyUrl.searchParams.set("redirect_uri", redirectUri); const proxyRes = await fetch(proxyUrl.toString()); - const proxyData = await proxyRes.json(); + const proxyData = await parseResponseBody(proxyRes); + if (!proxyRes.ok) throw new Error(getResponseErrorMessage(proxyRes, proxyData, "Codex OAuth proxy failed")); codexProxyActive = proxyData.success; codexServerSide = !!proxyData.serverSide; } catch { @@ -240,7 +243,8 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, proxyUrl.searchParams.set("code_verifier", data.codeVerifier); proxyUrl.searchParams.set("redirect_uri", redirectUri); const proxyRes = await fetch(proxyUrl.toString()); - const proxyData = await proxyRes.json(); + const proxyData = await parseResponseBody(proxyRes); + if (!proxyRes.ok) throw new Error(getResponseErrorMessage(proxyRes, proxyData, "Grok Build OAuth proxy failed")); xaiProxyActive = proxyData.success; xaiServerSide = !!proxyData.serverSide; if (!xaiProxyActive && proxyData.reason === "port_busy") { @@ -321,8 +325,9 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, if (cancelled || callbackProcessedRef.current) return; attempts += 1; try { - const res = await fetch(`/api/oauth/${pollProvider}/poll-status?state=${encodeURIComponent(authData.state)}`); - const data = await res.json(); + const res = await fetch(`/api/oauth/${pollProvider}/poll-status?state=${encodeURIComponent(authData.state)}`); + const data = await parseResponseBody(res); + if (!res.ok) throw new Error(getResponseErrorMessage(res, data, "OAuth status polling failed")); if (cancelled || callbackProcessedRef.current) return; if (data.status === "done") { callbackProcessedRef.current = true; diff --git a/src/shared/utils/api.js b/src/shared/utils/api.js index 5712ef9eeb8..aff24fdd125 100644 --- a/src/shared/utils/api.js +++ b/src/shared/utils/api.js @@ -75,11 +75,36 @@ export async function del(url, options = {}) { * @param {Response} response - Fetch response * @returns {Promise} */ -async function handleResponse(response) { - const data = await response.json(); +export async function parseResponseBody(response) { + const text = await response.text(); + if (!text) return {}; + + try { + return JSON.parse(text); + } catch { + return { error: text.trim() || "Invalid non-JSON response", rawText: text }; + } +} + +export function getResponseErrorMessage(response, data = {}, fallback = "Request failed") { + const rawError = data.error || data.errorDescription || data.message; + const message = typeof rawError === "object" + ? rawError.message || JSON.stringify(rawError) + : rawError; + const cleanMessage = String(message || fallback).trim(); + if (!response) return cleanMessage; + + const statusLabel = response.status + ? `${response.status}${response.statusText ? ` ${response.statusText}` : ""}` + : ""; + return statusLabel ? `${fallback} (${statusLabel}): ${cleanMessage}` : cleanMessage; +} + +export async function handleResponse(response) { + const data = await parseResponseBody(response); if (!response.ok) { - const error = new Error(data.error || "An error occurred"); + const error = new Error(getResponseErrorMessage(response, data, "An error occurred")); error.status = response.status; error.data = data; throw error; diff --git a/tests/unit/api-response.test.js b/tests/unit/api-response.test.js new file mode 100644 index 00000000000..1f2cf619ff2 --- /dev/null +++ b/tests/unit/api-response.test.js @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; + +import { + getResponseErrorMessage, + handleResponse, + parseResponseBody, +} from "../../src/shared/utils/api.js"; + +describe("shared API response helpers", () => { + it("parses JSON responses", async () => { + const response = new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + + await expect(parseResponseBody(response)).resolves.toEqual({ ok: true }); + }); + + it("turns non-JSON error responses into usable error data", async () => { + const response = new Response("Internal Server Error", { + status: 500, + statusText: "Internal Server Error", + headers: { "Content-Type": "text/plain" }, + }); + + const data = await parseResponseBody(response); + + expect(data.error).toBe("Internal Server Error"); + expect(getResponseErrorMessage(response, data, "OAuth authorization failed")).toBe( + "OAuth authorization failed (500 Internal Server Error): Internal Server Error" + ); + }); + + it("throws structured errors for JSON API failures", async () => { + const response = new Response(JSON.stringify({ error: "Nope" }), { + status: 400, + statusText: "Bad Request", + headers: { "Content-Type": "application/json" }, + }); + + await expect(handleResponse(response)).rejects.toMatchObject({ + message: "An error occurred (400 Bad Request): Nope", + status: 400, + data: { error: "Nope" }, + }); + }); +});