immich-app/immich · error · MalformedResponseError

Expected a JSON response

Error message

Expected a JSON response

What it means

The SDK wraps its fetch implementation with `jsonOnly`, a fetch middleware that verifies a response actually is JSON whenever the request's Accept header asks for JSON (and the status isn't 204). If the response's Content-Type header is missing or does not include 'json', a MalformedResponseError('Expected a JSON response') is thrown, carrying the URL, status, and content type. This guards callers against receiving HTML error pages, empty bodies, or binary data when a typed JSON response is expected.

Source

Thrown at packages/sdk/src/index.ts:66

    throw new Error('The API key header can only be set using setApiKey().');
  }
};

export const jsonOnly =
  (impl?: typeof fetch): typeof fetch =>
  async (input, options) => {
    const response = await (impl ?? fetch)(input, options);

    const expectsJson = new Headers(options?.headers)
      .get('accept')
      ?.includes('json');
    if (!expectsJson || response.status === 204) {
      return response;
    }

    const contentType = response.headers.get('content-type');
    if (!contentType?.includes('json')) {
      throw new MalformedResponseError(
        'Expected a JSON response',
        response.url,
        response.status,
        contentType,
      );
    }

    return response;
  };

export const setFetch = (impl: typeof fetch) => {
  defaults.fetch = jsonOnly(impl);
};

defaults.fetch = jsonOnly();

export const getAssetOriginalPath = (id: string) => `/assets/${id}/original`;

View on GitHub (pinned to 5666d57f15)

Solutions

  1. Check response.url/status/contentType in the MalformedResponseError to see what actually came back; usually the baseUrl is wrong or a proxy returned an HTML page.
  2. Verify baseUrl points at the API server root (not the web app) and is reachable without browser redirects.
  3. Check proxy/WAF/anti-bot layers (nginx, Cloudflare) and either fix them or add an allowlist for the API path.
  4. If you intentionally call a non-JSON endpoint (e.g. binary asset download), omit the Accept: application/json header from the request so jsonOnly skips the check.
  5. If using a custom fetch via setFetch, ensure it forwards the upstream Content-Type header unchanged.

Example fix

// before
init({ baseUrl: 'https://my-immich.example.com' }); // hits web app, gets HTML
// after
init({ baseUrl: 'https://my-immich.example.com/api' }); // hits JSON API
Defensive patterns

Strategy: try-catch

Validate before calling

// check what the server actually returns before trusting JSON
const res = await fetch(url, { headers: { accept: 'application/json' } });
const ct = res.headers.get('content-type') ?? '';
if (!ct.includes('json')) console.warn(`non-JSON from ${res.url}: ${ct}, status ${res.status}`);

Type guard

function isMalformedResponseError(e: unknown): e is MalformedResponseError {
  return e instanceof MalformedResponseError;
}

Try / catch

try {
  const res = await sdk.someJsonCall();
} catch (e) {
  if (isMalformedResponseError(e)) {
    console.error(`Non-JSON response from ${e.url} (status ${e.status}, content-type ${e.contentType})`);
  } else throw e;
}

Prevention

When it happens

Trigger: Any SDK call that sends Accept: application/json but whose server response Content-Type is not JSON — e.g. the server returns an HTML error/interstitial page (reverse proxy, login portal, Cloudflare block page), returns an empty body with text/plain, redirects to a non-JSON endpoint, or a custom fetch impl (setFetch) that mislabels or strips Content-Type.

Common situations: Wrong baseUrl pointing at a web UI instead of the API; a proxy or WAF intercepting requests and returning HTML 403/502 pages; misconfigured server behind auth that responds with a redirect to a login page; custom fetch implementations (via setFetch) that swallow or rewrite headers.

Related errors


AI-assisted analysis of immich-app/immich@5666d57f15 (2026-09-01). Data as JSON: /api/errors/21eb78e5c2e8b7af. Report an issue: GitHub.