Problem
This proposal suggests adopting Effect.ts in Tsarr to address four architectural pain points that are currently solved with manual, error-prone code.
Effect.ts can be adopted incrementally such as:
- Start by wrapping
createResilientFetch in an Effect, keeping the same function signature — the rest of the codebase doesn't need to know.
- Introduce
Data.TaggedError for the existing error classes — backward-compatible since Effect errors are still Error instances.
I also have a side question unrelated to effect, tsarr npm package went from 508 KB in version 2.10 to 1.03 MB
in version 2.11 how did that happened ?
Proposed solution
1. Retry & timeout logic → Effect.retry + Effect.timeout
Current state (src/core/fetch.ts)
A single 130-line function mixes:
- Timeout wiring (
AbortController + setTimeout/clearTimeout)
- Retry counting (manual
for loop)
- Exponential backoff with jitter (
initialDelay * 2^attempt + jitter)
- Caller-abort-signal merging
- Ad-hoc error classification (
instanceof DOMException, instanceof TypeError)
Specific issues:
- Timeout timers can leak —
clearTimeout sits inside a try/catch and some error paths miss it.
- Retry is only on HTTP 408/429/502/503/504 and network
TypeErrors — custom ConnectionError types thrown by layers above are invisible to the retry classifier.
- Response bodies are not drained before retrying (can prevent connection reuse on some platforms).
What Effect replaces it with
import { Effect, Schedule } from "effect";
const retrySchedule = Schedule.exponential("1 second").pipe(
Schedule.jittered,
Schedule.compose(Schedule.recurs(options.maxRetries)),
Schedule.whileInput((err) => isRetryableStatus(err) || isNetworkError(err))
);
const fetchEffect = Effect.tryPromise(() => fetchFn(request));
const resilientFetch = fetchEffect.pipe(
Effect.timeout(options.timeout),
Effect.retry(retrySchedule)
);
Gains
- ~110 fewer lines of maintenance burden.
- Built-in timer cleanup — no
clearTimeout to forget.
- First-class composability: timeout + retry + circuit breaking are independent layers that can be mixed without touching existing code.
- The scheduler handles backoff, jitter, and retry count without hand-rolled math.
2. Untyped errors → exhaustive error handling with Data.TaggedError
Current state
src/core/fetch.ts:26 — isRetryable(error: unknown) is completely invisible to the type system.
- Generated OpenAPI client (
client.gen.ts:198) — throws jsonError ?? textError, an untyped unknown.
src/cli/commands/service.ts:180 — inspects raw?.error?.status with ad-hoc string-key access. No guarantee the fields exist.
src/cli/commands/service.ts:363-408 — chain of instanceof checks (ApiKeyError, ConnectionError, TsarrError), then a final else branch at line 379 that calls consola.error('An unexpected error occurred.'). All error context is silently discarded.
src/core/errors.ts — clean class hierarchy, but consumers can only distinguish errors via instanceof. TypeScript cannot guarantee every case is handled.
What Effect replaces it with
import { Data } from "effect";
class ApiKeyError extends Data.TaggedError("ApiKeyError")<{
message: string;
details?: unknown;
}> {}
class ConnectionError extends Data.TaggedError("ConnectionError")<{
statusCode?: number;
url: string;
cause: unknown;
}> {}
// Consumer:
Effect.match(effect, {
onFailure: Effect.catchTags({
ApiKeyError: (e) => Console.error(`Invalid API key: ${e.message}`),
ConnectionError: (e) => Console.error(`Connection failed [${e.statusCode}]: ${e.url}`),
// Compiler error if any error type is not handled
}),
onSuccess: (result) => Console.log(result),
});
Gains
- Exhaustiveness checking — the compiler enforces that every error variant is handled. The "unexpected error" else-branch becomes impossible.
- Error data is typed per-variant — no
unknown casts, no ?.status guessing.
Effect.catchTags is a discriminated union dispatch, not a fragile instanceof chain.
3. Global singleton clients → scoped dependency injection with Layer
Current state
src/generated/radarr/client.gen.ts:16 — exports const client: Client = createClient(createConfig()), a module-level singleton.
- Bazarr (
src/clients/bazarr.ts:39), Seerr (seerr.ts:37), and QBittorrent (qbittorrent.ts:58) all call .setConfig() directly on this global. You cannot have two clients for the same service with different configs in one process.
ServarrBaseClient injects rawClient via constructor, but the injected object is still the global singleton — no way to create an isolated instance.
- CLI's
service.ts:43 declares clientFactory: (config: any) => any — completely untyped.
What Effect replaces it with
import { Effect, Layer } from "effect";
class RadarrClient extends Effect.Service<RadarrClient>()("RadarrClient", {
effect: Effect.gen(function* () {
const config = yield* RadarrConfig;
return yield* createRadarrClient(config);
}),
}) {}
// Per-instance isolation:
const program = Effect.gen(function* () {
const client = yield* RadarrClient;
return yield* client.getMovies();
}).pipe(
Effect.provide(RadarrClientLive(configA)) // scoped to this effect
);
// Two different configs coexist:
const program2 = Effect.gen(function* () {
const a = yield* program.pipe(Effect.provide(RadarrClientLive(configA)));
const b = yield* program.pipe(Effect.provide(RadarrClientLive(configB)));
});
Gains
- No more global mutation —
setConfig() on singletons disappears.
- Each effect scope gets a fresh, isolated client instance.
- CLI's
clientFactory: any => any becomes a typed Effect<Client, ConfigError, Config>.
- Generated clients don't need to change — only the wiring layer (handwritten code) changes.
4. Race condition on QBittorrent login → Effect.cached
Current state (src/clients/qbittorrent.ts:95-99)
private async ensureAuth(): Promise<string> {
if (!this.sid) { // race window here
await this.login(); // concurrent calls both enter login()
}
return this.sid!;
}
ensureAuth() checks this.sid, then calls this.login(). Between the check and the await, another concurrent request can also enter login(), triggering duplicate login calls against the qBittorrent server. This is a classic TOCTOU race.
What Effect replaces it with
import { Effect } from "effect";
const loginEffect = Effect.gen(function* () {
// perform login, return SID
return sid;
}).pipe(Effect.cached);
// guarantees single execution — all concurrent callers share the result
const ensureAuth = () => Effect.cachedGet(loginEffect);
Gains
Effect.cached provides exactly-once semantics for the login effect. All concurrent callers wait for the single in-flight login and share its result.
- No locks, no
if (!this.sid) guards, no manual synchronization.
- The
sid can also be modeled as a Ref<string | null> if invalidation/refresh is needed later.
Alternatives considered
No response
Surface
SDK
Additional context
No response
Problem
This proposal suggests adopting Effect.ts in Tsarr to address four architectural pain points that are currently solved with manual, error-prone code.
Effect.ts can be adopted incrementally such as:
createResilientFetchin an Effect, keeping the same function signature — the rest of the codebase doesn't need to know.Data.TaggedErrorfor the existing error classes — backward-compatible since Effect errors are stillErrorinstances.I also have a side question unrelated to effect, tsarr npm package went from 508 KB in version 2.10 to 1.03 MB
in version 2.11 how did that happened ?
Proposed solution
1. Retry & timeout logic →
Effect.retry+Effect.timeoutCurrent state (
src/core/fetch.ts)A single 130-line function mixes:
AbortController+setTimeout/clearTimeout)forloop)initialDelay * 2^attempt + jitter)instanceof DOMException,instanceof TypeError)Specific issues:
clearTimeoutsits inside atry/catchand some error paths miss it.TypeErrors — customConnectionErrortypes thrown by layers above are invisible to the retry classifier.What Effect replaces it with
Gains
clearTimeoutto forget.2. Untyped errors → exhaustive error handling with
Data.TaggedErrorCurrent state
src/core/fetch.ts:26—isRetryable(error: unknown)is completely invisible to the type system.client.gen.ts:198) — throwsjsonError ?? textError, an untypedunknown.src/cli/commands/service.ts:180— inspectsraw?.error?.statuswith ad-hoc string-key access. No guarantee the fields exist.src/cli/commands/service.ts:363-408— chain ofinstanceofchecks (ApiKeyError,ConnectionError,TsarrError), then a finalelsebranch at line 379 that callsconsola.error('An unexpected error occurred.'). All error context is silently discarded.src/core/errors.ts— clean class hierarchy, but consumers can only distinguish errors viainstanceof. TypeScript cannot guarantee every case is handled.What Effect replaces it with
Gains
unknowncasts, no?.statusguessing.Effect.catchTagsis a discriminated union dispatch, not a fragileinstanceofchain.3. Global singleton clients → scoped dependency injection with
LayerCurrent state
src/generated/radarr/client.gen.ts:16— exportsconst client: Client = createClient(createConfig()), a module-level singleton.src/clients/bazarr.ts:39), Seerr (seerr.ts:37), and QBittorrent (qbittorrent.ts:58) all call.setConfig()directly on this global. You cannot have two clients for the same service with different configs in one process.ServarrBaseClientinjectsrawClientvia constructor, but the injected object is still the global singleton — no way to create an isolated instance.service.ts:43declaresclientFactory: (config: any) => any— completely untyped.What Effect replaces it with
Gains
setConfig()on singletons disappears.clientFactory: any => anybecomes a typedEffect<Client, ConfigError, Config>.4. Race condition on QBittorrent login →
Effect.cachedCurrent state (
src/clients/qbittorrent.ts:95-99)ensureAuth()checksthis.sid, then callsthis.login(). Between the check and theawait, another concurrent request can also enterlogin(), triggering duplicate login calls against the qBittorrent server. This is a classic TOCTOU race.What Effect replaces it with
Gains
Effect.cachedprovides exactly-once semantics for the login effect. All concurrent callers wait for the single in-flight login and share its result.if (!this.sid)guards, no manual synchronization.sidcan also be modeled as aRef<string | null>if invalidation/refresh is needed later.Alternatives considered
No response
Surface
SDK
Additional context
No response