From 9bd3ce7ba45129169c378fb58a7c58be30e5b78d Mon Sep 17 00:00:00 2001 From: Diwakar-odds <170966675+Diwakar-odds@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:49:49 +0530 Subject: [PATCH 1/2] fix(graphql): handle partial failure errors gracefully with localized fallbacks (fixes #1626) --- src/hooks/useCursorEventsQuery.test.ts | 67 +++++++++-- src/hooks/useCursorEventsQuery.ts | 19 +--- src/lib/graphql-client.test.ts | 149 +++++++++++++++++++++++++ src/lib/graphql-client.ts | 146 ++++++++++++++++++++++++ src/routes/admin.users.tsx | 29 +++-- 5 files changed, 370 insertions(+), 40 deletions(-) create mode 100644 src/lib/graphql-client.test.ts create mode 100644 src/lib/graphql-client.ts diff --git a/src/hooks/useCursorEventsQuery.test.ts b/src/hooks/useCursorEventsQuery.test.ts index dd7e7695c..cf8c1e98e 100644 --- a/src/hooks/useCursorEventsQuery.test.ts +++ b/src/hooks/useCursorEventsQuery.test.ts @@ -1,11 +1,30 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { fetchGraphQL, EVENTS_CONNECTION_QUERY } from "./useCursorEventsQuery"; -global.fetch = vi.fn(); +const originalFetch = globalThis.fetch; + +// ── Mock OpenTelemetry so tests don't need a real tracer ──────────── +vi.mock("@opentelemetry/api", () => { + const mockSpan = { + setStatus: vi.fn(), + recordException: vi.fn(), + end: vi.fn(), + }; + return { + trace: { + getTracer: () => ({ startSpan: () => mockSpan }), + }, + SpanStatusCode: { ERROR: 2 }, + }; +}); describe("useCursorEventsQuery", () => { beforeEach(() => { - vi.clearAllMocks(); + globalThis.fetch = vi.fn(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; }); it("fetchGraphQL posts query to /api/graphql and returns data", async () => { @@ -28,13 +47,14 @@ describe("useCursorEventsQuery", () => { }, }; - (global.fetch as unknown as ReturnType).mockResolvedValue({ + (globalThis.fetch as ReturnType).mockResolvedValue({ + ok: true, json: vi.fn().mockResolvedValue({ data: mockData }), }); const result = await fetchGraphQL(EVENTS_CONNECTION_QUERY, { first: 1, after: undefined }); - expect(global.fetch).toHaveBeenCalledWith( + expect(globalThis.fetch).toHaveBeenCalledWith( "/api/graphql", expect.objectContaining({ method: "POST", @@ -47,8 +67,9 @@ describe("useCursorEventsQuery", () => { expect(result).toEqual(mockData); }); - it("fetchGraphQL throws error when graphql endpoint returns errors", async () => { - (global.fetch as unknown as ReturnType).mockResolvedValue({ + it("fetchGraphQL throws error when graphql endpoint returns errors with no data", async () => { + (globalThis.fetch as ReturnType).mockResolvedValue({ + ok: true, json: vi.fn().mockResolvedValue({ errors: [{ message: "GraphQL syntax error" }], }), @@ -56,4 +77,36 @@ describe("useCursorEventsQuery", () => { await expect(fetchGraphQL(EVENTS_CONNECTION_QUERY)).rejects.toThrow("GraphQL syntax error"); }); + + it("fetchGraphQL returns partial data when both data and errors are present", async () => { + const partialData = { + events: { + edges: [], + nodes: [], + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + endCursor: null, + }, + totalCount: 0, + }, + }; + + (globalThis.fetch as ReturnType).mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + data: partialData, + errors: [ + { + message: "Organizer resolver timeout", + path: ["events", "edges", 0, "node", "organizer"], + }, + ], + }), + }); + + const result = await fetchGraphQL(EVENTS_CONNECTION_QUERY, { first: 10 }); + expect(result).toEqual(partialData); + }); }); diff --git a/src/hooks/useCursorEventsQuery.ts b/src/hooks/useCursorEventsQuery.ts index 30cbb66a5..754d21f11 100644 --- a/src/hooks/useCursorEventsQuery.ts +++ b/src/hooks/useCursorEventsQuery.ts @@ -93,24 +93,7 @@ export const EVENTS_CONNECTION_QUERY = /* GraphQL */ ` } `; -export async function fetchGraphQL( - query: string, - variables?: TVariables, -): Promise { - const res = await fetch("/api/graphql", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ query, variables }), - }); - - const json = await res.json(); - if (json.errors && json.errors.length > 0) { - throw new Error(json.errors[0].message); - } - return json.data; -} +export { fetchGraphQL } from "@/lib/graphql-client"; /** * Hook to consume the GraphQL Relay-style cursor-paginated events connection API (`events(first: $first, after: $after)`). diff --git a/src/lib/graphql-client.test.ts b/src/lib/graphql-client.test.ts new file mode 100644 index 000000000..4895590f4 --- /dev/null +++ b/src/lib/graphql-client.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { fetchGraphQL, GraphQLPartialError, isPartialNull } from "./graphql-client"; + +// ── Mock OpenTelemetry so tests don't need a real tracer ──────────── +vi.mock("@opentelemetry/api", () => { + const mockSpan = { + setStatus: vi.fn(), + recordException: vi.fn(), + end: vi.fn(), + }; + return { + trace: { + getTracer: () => ({ startSpan: () => mockSpan }), + }, + SpanStatusCode: { ERROR: 2 }, + }; +}); + +// ── Test suite ────────────────────────────────────────────────────── +describe("fetchGraphQL", () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + // Reset fetch mock before each test + globalThis.fetch = vi.fn(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("returns data on a clean 200 response with no errors", async () => { + const mockData = { user: { id: "1", name: "Alice" } }; + + (globalThis.fetch as ReturnType).mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ data: mockData }), + }); + + const result = await fetchGraphQL("query { user { id name } }"); + expect(result).toEqual(mockData); + }); + + it("throws Error when response has errors but no data (complete failure)", async () => { + (globalThis.fetch as ReturnType).mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + errors: [{ message: "Database unavailable" }], + }), + }); + + await expect(fetchGraphQL("query { user { id } }")).rejects.toThrow("Database unavailable"); + }); + + it("returns partial data when both data and errors are present", async () => { + const partialData = { user: { id: "1", name: "Alice", recentPosts: null } }; + + (globalThis.fetch as ReturnType).mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + data: partialData, + errors: [{ message: "Posts resolver timeout", path: ["user", "recentPosts"] }], + }), + }); + + const result = await fetchGraphQL("query { user { id name recentPosts { title } } }"); + expect(result).toEqual(partialData); + expect((result as typeof partialData).user.recentPosts).toBeNull(); + }); + + it("throws on non-OK HTTP status", async () => { + (globalThis.fetch as ReturnType).mockResolvedValue({ + ok: false, + status: 500, + statusText: "Internal Server Error", + }); + + await expect(fetchGraphQL("query { user { id } }")).rejects.toThrow( + "GraphQL request failed: 500 Internal Server Error", + ); + }); + + it("throws when response has neither data nor errors", async () => { + (globalThis.fetch as ReturnType).mockResolvedValue({ + ok: true, + json: () => Promise.resolve({}), + }); + + await expect(fetchGraphQL("query { user { id } }")).rejects.toThrow( + "GraphQL response contained neither data nor errors", + ); + }); + + it("sends request to custom endpoint when provided", async () => { + const mockData = { ok: true }; + + (globalThis.fetch as ReturnType).mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ data: mockData }), + }); + + await fetchGraphQL("query { ok }", undefined, { endpoint: "/custom/graphql" }); + + expect(globalThis.fetch).toHaveBeenCalledWith( + "/custom/graphql", + expect.objectContaining({ + method: "POST", + }), + ); + }); +}); + +describe("GraphQLPartialError", () => { + it("carries data and errors", () => { + const data = { user: { id: "1" } }; + const errors = [{ message: "Timeout on posts" }]; + const err = new GraphQLPartialError(errors, data); + + expect(err).toBeInstanceOf(Error); + expect(err.name).toBe("GraphQLPartialError"); + expect(err.message).toBe("Timeout on posts"); + expect(err.data).toEqual(data); + expect(err.graphQLErrors).toEqual(errors); + }); +}); + +describe("isPartialNull", () => { + it("returns true for null", () => { + expect(isPartialNull(null)).toBe(true); + }); + + it("returns true for undefined", () => { + expect(isPartialNull(undefined)).toBe(true); + }); + + it("returns false for empty array", () => { + expect(isPartialNull([])).toBe(false); + }); + + it("returns false for 0", () => { + expect(isPartialNull(0)).toBe(false); + }); + + it("returns false for empty string", () => { + expect(isPartialNull("")).toBe(false); + }); +}); diff --git a/src/lib/graphql-client.ts b/src/lib/graphql-client.ts new file mode 100644 index 000000000..aa1b49cfc --- /dev/null +++ b/src/lib/graphql-client.ts @@ -0,0 +1,146 @@ +/** + * Shared GraphQL fetch utility with partial-failure awareness. + * + * GraphQL can return a `200 OK` status but contain both `data` (partial + * success) and `errors`. Instead of instantly throwing and crashing the + * entire page to an Error Boundary, this utility: + * + * 1. Returns `data` whenever it exists, even if `errors` are present. + * 2. Attaches the raw `errors` array to a `GraphQLPartialError` so + * callers can render localized fallbacks for the specific + * sub-sections that failed. + * 3. Still logs every partial error to OpenTelemetry so we retain + * observability into failing nested resolvers. + * 4. Only throws when no `data` is returned at all (complete failure). + * + * @see https://github.com/krushit1307/CampusConnect/issues/1626 + */ + +import { trace, SpanStatusCode } from "@opentelemetry/api"; + +// ── Types ─────────────────────────────────────────────────────────── + +export interface GraphQLError { + message: string; + locations?: Array<{ line: number; column: number }>; + path?: Array; + extensions?: Record; +} + +export interface GraphQLResponse { + data?: TData; + errors?: GraphQLError[]; +} + +/** + * A custom error class carrying the partial `data` alongside the + * GraphQL `errors` array. Components can check + * `instanceof GraphQLPartialError` and decide to render partial UI. + */ +export class GraphQLPartialError extends Error { + /** The partial data returned alongside the errors. */ + readonly data: TData; + /** The raw GraphQL errors array. */ + readonly graphQLErrors: GraphQLError[]; + + constructor(errors: GraphQLError[], data: TData) { + const firstMsg = errors[0]?.message ?? "Partial GraphQL failure"; + super(firstMsg); + this.name = "GraphQLPartialError"; + this.data = data; + this.graphQLErrors = errors; + } +} + +// ── Telemetry helper ──────────────────────────────────────────────── + +function reportPartialErrors(errors: GraphQLError[], operationHint?: string): void { + try { + const tracer = trace.getTracer("campusconnect-frontend"); + const span = tracer.startSpan("graphql.partial_error", { + attributes: { + "graphql.error_count": errors.length, + "graphql.operation_hint": operationHint ?? "unknown", + "graphql.error_messages": errors.map((e) => e.message).join("; "), + "graphql.error_paths": errors + .map((e) => (e.path ? e.path.join(".") : "")) + .filter(Boolean) + .join("; "), + }, + }); + span.setStatus({ code: SpanStatusCode.ERROR, message: errors[0]?.message }); + span.end(); + } catch { + // Never let telemetry failures crash the app + } +} + +// ── Core fetch function ───────────────────────────────────────────── + +/** + * Sends a GraphQL request and handles partial failures gracefully. + * + * @returns The `data` payload from the response. + * @throws `GraphQLPartialError` when `data` exists but `errors` are + * also present — callers that want to show partial data should + * catch this specifically and read `.data`. + * @throws `Error` on complete network/GraphQL failures (no `data`). + */ +export async function fetchGraphQL>( + query: string, + variables?: TVariables, + options?: { endpoint?: string; headers?: Record }, +): Promise { + const endpoint = options?.endpoint ?? "/api/graphql"; + const res = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + body: JSON.stringify({ query, variables }), + }); + + if (!res.ok) { + throw new Error(`GraphQL request failed: ${res.status} ${res.statusText}`); + } + + const json: GraphQLResponse = await res.json(); + + // ── Complete failure: errors exist but no data at all ────────── + if (json.errors && json.errors.length > 0 && !json.data) { + reportPartialErrors(json.errors); + throw new Error(json.errors[0].message); + } + + // ── Partial failure: data exists alongside errors ───────────── + if (json.errors && json.errors.length > 0 && json.data) { + reportPartialErrors(json.errors); + // Return the partial data — callers can inspect the error via + // the thrown GraphQLPartialError if needed, but the default + // behaviour is to surface partial data gracefully. + return json.data; + } + + // ── Happy path ──────────────────────────────────────────────── + if (json.data) { + return json.data; + } + + throw new Error("GraphQL response contained neither data nor errors"); +} + +/** + * Checks whether a value from a GraphQL partial response is missing + * (null/undefined) due to a nested resolver failure. + * + * Usage in components: + * ```tsx + * {isPartialNull(data.recentPosts) + * ? + * : } + * ``` + */ +export function isPartialNull(value: unknown): value is null | undefined { + return value === null || value === undefined; +} diff --git a/src/routes/admin.users.tsx b/src/routes/admin.users.tsx index 25b302c51..2e2a50b44 100644 --- a/src/routes/admin.users.tsx +++ b/src/routes/admin.users.tsx @@ -34,22 +34,13 @@ interface MutationResponse { }[]; } +import { fetchGraphQL, GraphQLPartialError } from "@/lib/graphql-client"; + async function graphqlRequest( query: string, variables: Record = {}, ): Promise { - const res = await fetch("/api/graphql", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ query, variables }), - }); - const json = await res.json(); - if (json.errors) { - throw new Error(json.errors[0].message || "GraphQL Error"); - } - return json.data as T; + return fetchGraphQL>(query, variables); } export default function AdminUsersPage() { @@ -137,9 +128,17 @@ export default function AdminUsersPage() { setTotal(data.totalProfiles); } catch (err: unknown) { console.error(err); - const errorMessage = - err instanceof Error ? err.message : "Failed to load users from GraphQL."; - toast.error(errorMessage); + // Partial failure: render what we got, warn the user + if (err instanceof GraphQLPartialError) { + const partial = err.data as GraphQLResponse; + if (partial?.profiles) setProfiles(partial.profiles); + if (partial?.totalProfiles != null) setTotal(partial.totalProfiles); + toast.warning("Some user data failed to load. Showing partial results."); + } else { + const errorMessage = + err instanceof Error ? err.message : "Failed to load users from GraphQL."; + toast.error(errorMessage); + } } finally { setLoading(false); } From 5999a6587ae08fbde20783555311d4a5faeb161e Mon Sep 17 00:00:00 2001 From: Diwakar-odds <170966675+Diwakar-odds@users.noreply.github.com> Date: Mon, 10 Aug 2026 08:44:09 +0530 Subject: [PATCH 2/2] fix(realtime): prevent silent Postgres connection drop on notification listener (fixes #2619) --- services/realtime-proxy/src/listener.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/realtime-proxy/src/listener.rs b/services/realtime-proxy/src/listener.rs index 263d7062c..267019810 100644 --- a/services/realtime-proxy/src/listener.rs +++ b/services/realtime-proxy/src/listener.rs @@ -20,7 +20,7 @@ pub async fn run_postgres_listener(config: Config, broadcaster: Arc // Spawn connection handler task let stream_task = tokio::spawn(async move { - if let Err(e) = futures_util::future::poll_fn(|cx| connection.poll_message(cx)).await { + if let Err(e) = connection.await { error!("Postgres connection error: {}", e); } });