fix(servarr): add timeout to Radarr/Sonarr API requests to prevent infinite loading (#2375)

* fix(servarr): add timeout to Radarr/Sonarr API requests to prevent infinite loading

Adds a 5-second timeout to all Radarr/Sonarr API requests and displays a warning banner when
services are unreachable. This prevents the Recent Requests section and request list pages from
hanging indefinitely when a configured service has connection issues.

fix #2374

* fix(requests): only show service error banner to users with advanced permissions
This commit is contained in:
fallenbagel
2026-02-07 01:38:21 +05:00
committed by GitHub
parent a0a784b976
commit faa2c0a005
9 changed files with 109 additions and 7 deletions

View File

@@ -92,11 +92,13 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
apiKey,
cacheName,
apiName,
timeout = 5000,
}: {
url: string;
apiKey: string;
cacheName: AvailableCacheIds;
apiName: string;
timeout?: number;
}) {
super(
url,
@@ -105,6 +107,7 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
},
{
nodeCache: cacheManager.getCache(cacheName).data,
timeout,
}
);

View File

@@ -64,8 +64,16 @@ export interface RadarrMovie {
}
class RadarrAPI extends ServarrBase<{ movieId: number }> {
constructor({ url, apiKey }: { url: string; apiKey: string }) {
super({ url, apiKey, cacheName: 'radarr', apiName: 'Radarr' });
constructor({
url,
apiKey,
timeout,
}: {
url: string;
apiKey: string;
timeout?: number;
}) {
super({ url, apiKey, cacheName: 'radarr', apiName: 'Radarr', timeout });
}
public getMovies = async (): Promise<RadarrMovie[]> => {

View File

@@ -111,8 +111,16 @@ class SonarrAPI extends ServarrBase<{
episodeId: number;
episode: EpisodeResult;
}> {
constructor({ url, apiKey }: { url: string; apiKey: string }) {
super({ url, apiKey, apiName: 'Sonarr', cacheName: 'sonarr' });
constructor({
url,
apiKey,
timeout,
}: {
url: string;
apiKey: string;
timeout?: number;
}) {
super({ url, apiKey, apiName: 'Sonarr', cacheName: 'sonarr', timeout });
}
public async getSeries(): Promise<SonarrSeries[]> {

View File

@@ -7,6 +7,10 @@ export interface RequestResultsResponse extends PaginatedResponse {
profileName?: string;
canRemove?: boolean;
})[];
serviceErrors: {
radarr: { id: number; name: string }[];
sonarr: { id: number; name: string }[];
};
}
export type MediaRequestBody = {

View File

@@ -275,6 +275,24 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
page: Math.ceil(skip / pageSize) + 1,
},
results: mappedRequests,
serviceErrors: {
radarr: radarrServers
.filter((s) => !s.profiles)
.map((s) => ({
id: s.id,
name:
settings.radarr.find((r) => r.id === s.id)?.name ||
`Radarr ${s.id}`,
})),
sonarr: sonarrServers
.filter((s) => !s.profiles)
.map((s) => ({
id: s.id,
name:
settings.sonarr.find((r) => r.id === s.id)?.name ||
`Sonarr ${s.id}`,
})),
},
});
} catch (e) {
next({ status: 500, message: e.message });

View File

@@ -1,14 +1,25 @@
import { sliderTitles } from '@app/components/Discover/constants';
import RequestCard from '@app/components/RequestCard';
import Slider from '@app/components/Slider';
import { ArrowRightCircleIcon } from '@heroicons/react/24/outline';
import { Permission, useUser } from '@app/hooks/useUser';
import defineMessages from '@app/utils/defineMessages';
import {
ArrowRightCircleIcon,
ExclamationTriangleIcon,
} from '@heroicons/react/24/outline';
import type { RequestResultsResponse } from '@server/interfaces/api/requestInterfaces';
import Link from 'next/link';
import { useIntl } from 'react-intl';
import useSWR from 'swr';
const messages = defineMessages('components.Discover.RecentRequestsSlider', {
unableToConnect:
'Unable to connect to {services}. Some information may be unavailable.',
});
const RecentRequestsSlider = () => {
const intl = useIntl();
const { hasPermission } = useUser();
const { data: requests, error: requestError } =
useSWR<RequestResultsResponse>(
'/api/v1/request?filter=all&take=10&sort=modified&skip=0',
@@ -21,6 +32,11 @@ const RecentRequestsSlider = () => {
return null;
}
const hasServiceErrors =
requests?.serviceErrors &&
(requests.serviceErrors.radarr.length > 0 ||
requests.serviceErrors.sonarr.length > 0);
return (
<>
<div className="slider-header">
@@ -29,6 +45,23 @@ const RecentRequestsSlider = () => {
<ArrowRightCircleIcon />
</Link>
</div>
{hasServiceErrors &&
(hasPermission(Permission.MANAGE_REQUESTS) ||
hasPermission(Permission.REQUEST_ADVANCED)) && (
<div className="service-error-banner">
<ExclamationTriangleIcon className="h-5 w-5 flex-shrink-0" />
<span>
{intl.formatMessage(messages.unableToConnect, {
services: [
...requests.serviceErrors.radarr.map((s) => s.name),
...requests.serviceErrors.sonarr.map((s) => s.name),
].join(', '),
})}
</span>
</div>
)}
<Slider
sliderKey="requests"
isLoading={!requests}

View File

@@ -5,9 +5,10 @@ import PageTitle from '@app/components/Common/PageTitle';
import Tooltip from '@app/components/Common/Tooltip';
import RequestItem from '@app/components/RequestList/RequestItem';
import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
import { useUser } from '@app/hooks/useUser';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline';
import {
ArrowDownIcon,
ArrowUpIcon,
@@ -30,6 +31,8 @@ const messages = defineMessages('components.RequestList', {
sortAdded: 'Most Recent',
sortModified: 'Last Modified',
sortDirection: 'Toggle Sort Direction',
unableToConnect:
'Unable to connect to {services}. Some information may be unavailable.',
});
enum Filter {
@@ -56,7 +59,7 @@ const RequestList = () => {
const { user } = useUser({
id: Number(router.query.userId),
});
const { user: currentUser } = useUser();
const { user: currentUser, hasPermission } = useUser();
const [currentFilter, setCurrentFilter] = useState<Filter>(Filter.PENDING);
const [currentSort, setCurrentSort] = useState<Sort>('added');
const [currentMediaType, setCurrentMediaType] = useState<string>('all');
@@ -288,6 +291,25 @@ const RequestList = () => {
</div>
</div>
</div>
{data.serviceErrors &&
(data.serviceErrors.radarr.length > 0 ||
data.serviceErrors.sonarr.length > 0) &&
(hasPermission(Permission.MANAGE_REQUESTS) ||
hasPermission(Permission.REQUEST_ADVANCED)) && (
<div className="service-error-banner">
<ExclamationTriangleIcon className="h-5 w-5 flex-shrink-0" />
<span>
{intl.formatMessage(messages.unableToConnect, {
services: [
...data.serviceErrors.radarr.map((s) => s.name),
...data.serviceErrors.sonarr.map((s) => s.name),
].join(', '),
})}
</span>
</div>
)}
{data.results.map((request) => {
return (
<div className="py-2" key={`request-list-${request.id}`}>

View File

@@ -102,6 +102,7 @@
"components.Discover.PlexWatchlistSlider.emptywatchlist": "Media added to your <PlexWatchlistSupportLink>Plex Watchlist</PlexWatchlistSupportLink> will appear here.",
"components.Discover.PlexWatchlistSlider.plexwatchlist": "Your Watchlist",
"components.Discover.RecentlyAddedSlider.recentlyAdded": "Recently Added",
"components.Discover.RecentRequestsSlider.unableToConnect": "Unable to connect to {services}. Some information may be unavailable.",
"components.Discover.StudioSlider.studios": "Studios",
"components.Discover.TvGenreList.seriesgenres": "Series Genres",
"components.Discover.TvGenreSlider.tvgenres": "Series Genres",
@@ -515,6 +516,7 @@
"components.RequestList.sortAdded": "Most Recent",
"components.RequestList.sortDirection": "Toggle Sort Direction",
"components.RequestList.sortModified": "Last Modified",
"components.RequestList.unableToConnect": "Unable to connect to {services}. Some information may be unavailable.",
"components.RequestModal.AdvancedRequester.advancedoptions": "Advanced",
"components.RequestModal.AdvancedRequester.animenote": "* This series is an anime.",
"components.RequestModal.AdvancedRequester.default": "{name} (Default)",

View File

@@ -105,6 +105,10 @@
@apply relative mb-4 mt-6 flex;
}
.service-error-banner {
@apply mb-2 flex items-center gap-2 rounded-md border border-yellow-500 bg-yellow-500 bg-opacity-20 px-3 py-2 text-sm text-yellow-200;
}
.slider-title {
@apply inline-flex items-center text-xl font-bold leading-7 text-gray-300 sm:truncate sm:text-2xl sm:leading-9;
}