ruvnet/ruflo · error · Error

Failed to fetch ${url}: ${response.status} ${response.status

Error message

Failed to fetch ${url}: ${response.status} ${response.statusText}

What it means

Generic non-2xx guard in the shared fetchJSON<T> helper used across the client. It calls (options?.fetch ?? fetch)(url) and throws when response.ok is false, embedding the URL, status code, and statusText. It does not read the body, so the error never contains the server's JSON error detail.

Source

Thrown at ruflo/src/ruvocal/src/lib/utils/fetchJSON.ts:10

export async function fetchJSON<T>(
	url: string,
	options?: {
		fetch?: typeof window.fetch;
		allowNull?: boolean;
	}
): Promise<T> {
	const response = await (options?.fetch ?? fetch)(url);
	if (!response.ok) {
		throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
	}

	// Handle empty responses (which parse to null)
	const text = await response.text();
	if (!text || text.trim() === "") {
		if (options?.allowNull) {
			return null as T;
		}
		throw new Error(`Received empty response from ${url} but allowNull is not set to true`);
	}

	return JSON.parse(text);
}

View on GitHub (pinned to 6b01dc5a68)

Solutions

  1. Read response.status from the error text to classify (4xx = caller fault, 5xx = server).
  2. For 401/403, ensure credentials/tokens are attached to the request (fetchJSON does not add auth headers itself).
  3. For 404, confirm the URL and the API version path.
  4. For 429/5xx, wrap the call in a retry with exponential backoff.
  5. If you need the body, switch to a custom fetch wrapper that reads response.text() before throwing.

Example fix

// before
const response = await (options?.fetch ?? fetch)(url);
if (!response.ok) throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
// after
const response = await (options?.fetch ?? fetch)(url);
if (!response.ok) {
  const detail = await response.text().catch(() => "");
  throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText} ${detail.slice(0, 200)}`);
}
Defensive patterns

Strategy: retry

Validate before calling

async function canFetch(url: string): Promise<boolean> {
  try {
    const r = await fetch(url, { method: "HEAD" });
    return r.ok || r.status === 405; // some endpoints disallow HEAD
  } catch {
    return false;
  }
}

Try / catch

async function fetchJSONRetry<T>(url: string, attempts = 3): Promise<T> {
  let lastErr: unknown;
  for (let i = 0; i < attempts; i++) {
    try {
      return await fetchJSON<T>(url);
    } catch (e) {
      lastErr = e;
      const status = Number(String((e as Error)?.message).match(/:\s*(\d{3})/)?.[1] ?? 0);
      if (status >= 400 && status < 500 && status !== 429) throw e; // do not retry caller faults
      await new Promise((r) => setTimeout(r, 2 ** i * 200));
    }
  }
  throw lastErr;
}

Prevention

When it happens

Trigger: Any fetchJSON call against an endpoint that returns 4xx/5xx: unauthenticated (401), forbidden (403), not found (404), upstream error (502/503), or a rate-limit (429).

Common situations: Forgetting to send an auth token; hitting a route that only exists in v2 while calling v1; the upstream model/API being down; CORS preflight rejected and surfaced as an opaque non-OK response.

Related errors


AI-assisted analysis of ruvnet/ruflo@6b01dc5a68 (2026-08-12). Data as JSON: /api/errors/52f613e7b98843b9. Report an issue: GitHub.