immich-app/immich · error · MalformedResponseError
Expected a JSON response
Error message
Expected a JSON response
What it means
The SDK's jsonOnly fetch wrapper expects every non-204 response to a JSON-requested endpoint to have a JSON content-type. If the server returns non-JSON (HTML login page, empty error body, wrong route), MalformedResponseError('Expected a JSON response') is thrown carrying the URL, status, and content-type so the caller can diagnose what the server actually returned.
Solutions
- Check the URL/status/content-type embedded in the MalformedResponseError to see what the server actually replied with.
- Verify the base URL points at your Immich instance's API (no trailing path mistakes) and there is no auth portal intercepting requests.
- Inspect the response outside the SDK (curl -i) to see the raw body.
- Upgrade SDK and server to matching versions if an endpoint's content type changed.
Example fix
// before
new ImmichApi({ baseUrl: 'https://my-proxy.example.com' }) // proxy serves HTML
// after
new ImmichApi({ baseUrl: 'https://immich.example.com/api' }) // direct JSON API Defensive patterns
Strategy: try-catch
Validate before calling
const res = await fetch(`${baseUrl}/server-info/ping`);
const ct = res.headers.get('content-type') ?? '';
if (!ct.includes('json')) throw new Error(`baseUrl returns non-JSON (${ct}) - check URL/proxy`); Try / catch
try {
await api.someCall();
} catch (e) {
if (e instanceof MalformedResponseError) {
console.error(`non-JSON from ${e.url} (status ${e.status}, ct ${e.contentType}); check baseUrl/proxy`);
} else throw e;
} Prevention
- Ping /server-info/ping at startup to confirm the endpoint speaks JSON
- Point baseUrl directly at the Immich API, bypassing auth portals/CDN challenges
- Keep SDK and server versions in sync
When it happens
Trigger: Requesting an Immich endpoint whose response lacks 'json' in content-type: hitting an HTML 404/402 page, a proxy error page, or a route returning text/plain; also wrong base URL pointing at a non-Immich server.
Common situations: Base URL typo or reverse proxy intercepting the request (auth portal, Cloudflare challenge), server returning an HTML error for a missing route, or an old SDK talking to an endpoint that changed its response type.
Related errors
- await response.text()
- EOFException
- errors.unable_to_upload_file
- Failed to decode redis options
- Failed to fetch activation key
AI-assisted analysis of immich-app/immich@e55ac299a4 (2026-09-15).
Data as JSON: /api/errors/21eb78e5c2e8b7af.
Report an issue: GitHub.
Appendix: 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 e55ac299a4)