Skip to content

Adopt Effect.ts #220

Description

@OzakIOne

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:26isRetryable(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 mutationsetConfig() 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions