Skip to content

Commit d0837d9

Browse files
feat(core): add resilient fetch with timeout and retry across all clients (#189)
* feat(core): add resilient fetch with timeout and retry across all clients Closes #179 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(clients): centralize configureRawClient in base class Move the repeated setConfig({ baseUrl, headers, fetch }) call from each subclass into ServarrBaseClient. Subclasses now just pass their generated client to super() instead of duplicating the configuration logic. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: preserve timeout semantics and retry request bodies * fix: align biome schema with CI --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4116257 commit d0837d9

15 files changed

Lines changed: 461 additions & 23 deletions

File tree

biome.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"$schema": "https://biomejs.dev/schemas/2.4.6/schema.json",
2+
"$schema": "https://biomejs.dev/schemas/2.4.12/schema.json",
33
"vcs": {
44
"enabled": true,
55
"clientKind": "git",

src/clients/base.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,23 +72,31 @@ export interface ServarrOps {
7272
updateUiConfig: ApiCall;
7373
}
7474

75+
interface RawClient {
76+
setConfig(config: Record<string, unknown>): unknown;
77+
}
78+
7579
export abstract class ServarrBaseClient {
7680
protected clientConfig: ReturnType<typeof createServarrClient>;
81+
protected readonly rawClient: RawClient;
7782

7883
protected abstract readonly ops: ServarrOps;
7984

80-
constructor(config: ServarrClientConfig) {
85+
constructor(config: ServarrClientConfig, rawClient: RawClient) {
86+
this.rawClient = rawClient;
8187
this.clientConfig = createServarrClient(config);
8288
this.configureRawClient();
8389
}
8490

85-
protected abstract configureRawClient(): void;
91+
protected configureRawClient(): void {
92+
this.rawClient.setConfig(this.getClientConfig());
93+
}
8694

8795
protected getClientConfig() {
8896
return {
8997
baseUrl: this.clientConfig.getBaseUrl(),
9098
headers: this.clientConfig.getHeaders(),
91-
signal: AbortSignal.timeout(this.clientConfig.getTimeout()),
99+
fetch: this.clientConfig.getFetch(),
92100
};
93101
}
94102

src/clients/bazarr.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ export class BazarrClient {
4040
baseUrl: getBazarrApiBaseUrl(this.clientConfig.getBaseUrl()),
4141
headers: getBazarrHeaders(this.clientConfig),
4242
auth: this.clientConfig.config.apiKey,
43-
signal: AbortSignal.timeout(this.clientConfig.getTimeout()),
43+
fetch: this.clientConfig.getFetch(),
4444
});
4545
}
4646

@@ -700,7 +700,7 @@ export class BazarrClient {
700700
baseUrl: getBazarrApiBaseUrl(this.clientConfig.getBaseUrl()),
701701
headers: getBazarrHeaders(this.clientConfig),
702702
auth: this.clientConfig.config.apiKey,
703-
signal: AbortSignal.timeout(this.clientConfig.getTimeout()),
703+
fetch: this.clientConfig.getFetch(),
704704
});
705705

706706
return this.clientConfig.config;

src/clients/lidarr.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { ServarrBaseClient, type ServarrOps } from '../clients/base';
2+
import type { ServarrClientConfig } from '../core/types';
23
import { client as lidarrClient } from '../generated/lidarr/client.gen';
34
import * as LidarrApi from '../generated/lidarr/index';
45
import type {
@@ -98,8 +99,8 @@ export class LidarrClient extends ServarrBaseClient {
9899
updateUiConfig: LidarrApi.putApiV1ConfigUiById,
99100
};
100101

101-
protected configureRawClient(): void {
102-
lidarrClient.setConfig(this.getClientConfig());
102+
constructor(config: ServarrClientConfig) {
103+
super(config, lidarrClient);
103104
}
104105

105106
// Artist APIs

src/clients/prowlarr.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { ServarrBaseClient, type ServarrOps } from '../clients/base';
2+
import type { ServarrClientConfig } from '../core/types';
23
import { client as prowlarrClient } from '../generated/prowlarr/client.gen';
34
import * as ProwlarrApi from '../generated/prowlarr/index';
45
import type {
@@ -89,8 +90,8 @@ export class ProwlarrClient extends ServarrBaseClient {
8990
updateUiConfig: ProwlarrApi.putApiV1ConfigUiById,
9091
};
9192

92-
protected configureRawClient(): void {
93-
prowlarrClient.setConfig(this.getClientConfig());
93+
constructor(config: ServarrClientConfig) {
94+
super(config, prowlarrClient);
9495
}
9596

9697
// Prowlarr-specific APIs

src/clients/qbittorrent.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { ConnectionError } from '../core/errors';
2+
import { createResilientFetch } from '../core/fetch';
23
import type { QBittorrentClientConfig } from '../core/types';
34
import { client as qbittorrentClient } from '../generated/qbittorrent/client.gen';
45
import * as QBittorrentApi from '../generated/qbittorrent/index';
@@ -33,7 +34,7 @@ export class QBittorrentClient {
3334
private username: string;
3435
private password: string;
3536
private sid: string | null = null;
36-
private timeoutMs: number;
37+
private fetch: typeof globalThis.fetch;
3738

3839
constructor(config: QBittorrentClientConfig) {
3940
if (!config.baseUrl) {
@@ -43,12 +44,15 @@ export class QBittorrentClient {
4344
this.baseUrl = config.baseUrl.replace(/\/$/, '');
4445
this.username = config.username;
4546
this.password = config.password;
46-
this.timeoutMs = config.timeout ?? DEFAULT_TIMEOUT_MS;
47+
this.fetch = createResilientFetch({
48+
timeout: config.timeout ?? DEFAULT_TIMEOUT_MS,
49+
retry: config.retry,
50+
});
4751

4852
qbittorrentClient.setConfig({
4953
baseUrl: `${this.baseUrl}/api/v2`,
5054
auth: () => this.ensureAuth(),
51-
signal: AbortSignal.timeout(this.timeoutMs),
55+
fetch: this.fetch,
5256
});
5357
}
5458

@@ -60,7 +64,7 @@ export class QBittorrentClient {
6064
}
6165

6266
private async login(): Promise<void> {
63-
const response = await fetch(`${this.baseUrl}/api/v2/auth/login`, {
67+
const response = await this.fetch(`${this.baseUrl}/api/v2/auth/login`, {
6468
method: 'POST',
6569
headers: {
6670
'Content-Type': 'application/x-www-form-urlencoded',
@@ -70,7 +74,6 @@ export class QBittorrentClient {
7074
username: this.username,
7175
password: this.password,
7276
}),
73-
signal: AbortSignal.timeout(this.timeoutMs),
7477
});
7578

7679
if (!response.ok) {

src/clients/radarr.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { ServarrBaseClient, type ServarrOps } from '../clients/base';
2+
import type { ServarrClientConfig } from '../core/types';
23
import { client as radarrClient } from '../generated/radarr/client.gen';
34
import * as RadarrApi from '../generated/radarr/index';
45
import type {
@@ -84,8 +85,8 @@ export class RadarrClient extends ServarrBaseClient {
8485
updateUiConfig: RadarrApi.putApiV3ConfigUiById,
8586
};
8687

87-
protected configureRawClient(): void {
88-
radarrClient.setConfig(this.getClientConfig());
88+
constructor(config: ServarrClientConfig) {
89+
super(config, radarrClient);
8990
}
9091

9192
// Movie APIs

src/clients/readarr.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { ServarrBaseClient, type ServarrOps } from '../clients/base';
2+
import type { ServarrClientConfig } from '../core/types';
23
import { client as readarrClient } from '../generated/readarr/client.gen';
34
import * as ReadarrApi from '../generated/readarr/index';
45
import type {
@@ -98,8 +99,8 @@ export class ReadarrClient extends ServarrBaseClient {
9899
updateUiConfig: ReadarrApi.putApiV1ConfigUiById,
99100
};
100101

101-
protected configureRawClient(): void {
102-
readarrClient.setConfig(this.getClientConfig());
102+
constructor(config: ServarrClientConfig) {
103+
super(config, readarrClient);
103104
}
104105

105106
// Author APIs

src/clients/seerr.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ export class SeerrClient {
4040
'X-Api-Key': this.clientConfig.config.apiKey,
4141
...(this.clientConfig.config.headers ?? {}),
4242
},
43-
signal: AbortSignal.timeout(this.clientConfig.getTimeout()),
43+
fetch: this.clientConfig.getFetch(),
4444
});
4545
}
4646

@@ -140,7 +140,7 @@ export class SeerrClient {
140140
'X-Api-Key': this.clientConfig.config.apiKey,
141141
...(this.clientConfig.config.headers ?? {}),
142142
},
143-
signal: AbortSignal.timeout(this.clientConfig.getTimeout()),
143+
fetch: this.clientConfig.getFetch(),
144144
});
145145

146146
return this.clientConfig.config;

src/clients/sonarr.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { ServarrBaseClient, type ServarrOps } from '../clients/base';
2+
import type { ServarrClientConfig } from '../core/types';
23
import { client as sonarrClient } from '../generated/sonarr/client.gen';
34
import * as SonarrApi from '../generated/sonarr/index';
45
import type {
@@ -98,8 +99,8 @@ export class SonarrClient extends ServarrBaseClient {
9899
updateUiConfig: SonarrApi.putApiV3ConfigUiById,
99100
};
100101

101-
protected configureRawClient(): void {
102-
sonarrClient.setConfig(this.getClientConfig());
102+
constructor(config: ServarrClientConfig) {
103+
super(config, sonarrClient);
103104
}
104105

105106
// Override since Sonarr doesn't have generated system status endpoints

0 commit comments

Comments
 (0)