fix: throw a typed error on malformed api responses (#31006)

* fix: throw a typed error on malformed api responses

* fix: drop response body parse check

* fix(web): keep json response validation when overriding fetch (#31008)

* fix(cli): report malformed api responses (#31007)
This commit is contained in:
bo0tzz 2026-08-26 02:38:39 +02:00 committed by GitHub
parent 4ff7148f85
commit 6221e29c70
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 70 additions and 4 deletions

View file

@ -1,4 +1,12 @@
import { ApiKeyResponseDto, getMyApiKey, getMyUser, init, isHttpError, Permission } from '@immich/sdk';
import {
ApiKeyResponseDto,
getMyApiKey,
getMyUser,
init,
isHttpError,
isMalformedResponseError,
Permission,
} from '@immich/sdk';
import { convertPathToPattern, glob } from 'fast-glob';
import { createHash } from 'node:crypto';
import { createReadStream } from 'node:fs';
@ -82,7 +90,7 @@ export const connect = async (url: string, key: string) => {
init({ baseUrl: url, apiKey: key });
const [error] = await withError(getMyUser());
if (isHttpError(error)) {
if (isHttpError(error) || isMalformedResponseError(error)) {
logError(error, `Failed to connect to server ${url}`);
process.exit(1);
}
@ -94,6 +102,11 @@ export const logError = (error: unknown, message: string) => {
if (isHttpError(error)) {
console.error(`${message}: ${error.status}`);
console.error(JSON.stringify(error.data, undefined, 2));
} else if (isMalformedResponseError(error)) {
console.error(`${message}: ${error.message}`);
console.error(
'Check that the URL points at the Immich API, and that nothing in front of it (reverse proxy, SSO portal) is answering instead.',
);
} else {
console.error(`${message} - ${error}`);
}

View file

@ -20,3 +20,24 @@ export interface ApiHttpError extends HttpError {
export function isHttpError(error: unknown): error is ApiHttpError {
return error instanceof HttpError;
}
export class MalformedResponseError extends Error {
override name = 'MalformedResponseError';
constructor(
reason: string,
readonly url: string,
readonly status: number,
readonly contentType: string | null,
) {
super(
`${reason} (${url}, HTTP ${status}, content-type: ${contentType ?? 'none'})`,
);
}
}
export function isMalformedResponseError(
error: unknown,
): error is MalformedResponseError {
return error instanceof MalformedResponseError;
}

View file

@ -1,4 +1,5 @@
import { defaults } from './fetch-client.js';
import { MalformedResponseError } from './fetch-errors.js';
export * from './fetch-client.js';
export * from './fetch-errors.js';
@ -48,6 +49,37 @@ const assertNoApiKey = (headerKey: string) => {
}
};
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`;
export const getAssetThumbnailPath = (id: string) => `/assets/${id}/thumbnail`;

View file

@ -1,4 +1,4 @@
import { defaults } from '@immich/sdk';
import { setFetch } from '@immich/sdk';
import { memoize } from 'lodash-es';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
@ -11,7 +11,7 @@ async function _init(fetch: Fetch) {
// set event.fetch on the fetch-client used by @immich/sdk
// https://kit.svelte.dev/docs/load#making-fetch-requests
// https://github.com/oazapfts/oazapfts/blob/main/README.md#fetch-options
defaults.fetch = fetch;
setFetch(fetch);
await initLanguage();
await serverConfigManager.init();
await authManager.load();