Skip to content

Instantly share code, notes, and snippets.

@lucas-barake
Created January 18, 2025 17:59
Show Gist options
  • Save lucas-barake/951aa972e2e72f825650166bcd6c5522 to your computer and use it in GitHub Desktop.
Save lucas-barake/951aa972e2e72f825650166bcd6c5522 to your computer and use it in GitHub Desktop.
Refresh Token Strategy w/ Effect

Code

import { HttpBody, HttpClient, HttpClientRequest, HttpClientResponse } from "@effect/platform"
import { Data, DateTime, Effect, Function, Option, Ref, Schedule, Schema, SubscriptionRef } from "effect"

class AuthTokens extends Schema.Class<AuthTokens>("AuthTokens")({
  accessToken: Schema.String.pipe(Schema.Redacted),
  refreshToken: Schema.String.pipe(Schema.Redacted)
}) {}

class ForceRetryError extends Data.TaggedError("ForceRetryError")<{
  readonly newTokens: AuthTokens
}> {}

class NoAuthTokensStoredError extends Data.TaggedError("NoAuthTokensStoredError") {}

class Session extends Effect.Service<Session>()("Session", {
  dependencies: [],
  effect: Effect.gen(function*() {
    const httpClient = (yield* HttpClient.HttpClient).pipe(
      HttpClient.filterStatusOk
    )

    const semaphore = yield* Effect.makeSemaphore(1)
    const authTokens = yield* SubscriptionRef.make<Option.Option<AuthTokens>>(Option.none())
    const tokensLatch = yield* Effect.makeLatch(true)

    const timeSinceLastRefresh = yield* Ref.make<Option.Option<DateTime.DateTime>>(Option.none())

    const refresh = Effect.gen(function*() {
      yield* tokensLatch.close

      const tokens = yield* SubscriptionRef.get(authTokens).pipe(
        Effect.filterOrFail((tokens) => Option.isSome(tokens), () => new NoAuthTokensStoredError())
      )
      const now = yield* DateTime.now
      const hasRecentlyRefreshed = Option.match(yield* timeSinceLastRefresh, {
        onSome: DateTime.greaterThan(DateTime.subtract(now, { minutes: 5 })),
        onNone: Function.constant(false)
      })
      if (hasRecentlyRefreshed) {
        return yield* new ForceRetryError({ newTokens: tokens.value })
      }

      const newTokens = yield* httpClient.post("...", {
        body: HttpBody.unsafeJson({
          refreshToken: tokens.value.refreshToken
        })
      }).pipe(
        Effect.flatMap(HttpClientResponse.schemaBodyJson(AuthTokens)),
        Effect.catchTag("ParseError", Effect.die),
        Effect.catchIf(
          (error) => error._tag === "ResponseError" && error.response.status === 401,
          () => Effect.dieMessage("[Session.refresh]: Definitely not authenticated")
        ),
        Effect.timeout("5 seconds"),
        Effect.retry({
          times: 3,
          Schedule: Schedule.jittered(Schedule.exponential("1.5 seconds", 1.5))
        }),
        Effect.orDie
      )

      yield* SubscriptionRef.update(authTokens, () => Option.some(newTokens))

      return yield* new ForceRetryError({ newTokens })
    }).pipe(
      Effect.withSpan("Session.refresh"),
      Effect.ensuring(tokensLatch.open),
      semaphore.withPermits(1)
    )

    return {
      getTokens: tokensLatch.whenOpen(SubscriptionRef.get(authTokens)),
      changes: authTokens.changes,
      refresh
    }
  })
}) {}

class CustomHttpClient extends Effect.Service<CustomHttpClient>()("CustomHttpClient", {
  dependencies: [Session.Default],
  effect: Effect.gen(function*() {
    const session = yield* Session

    return (yield* HttpClient.HttpClient).pipe(
      HttpClient.mapRequestEffect(Effect.fn(function*(request) {
        const tokens = yield* session.getTokens.pipe(
          Effect.filterOrDieMessage((tokens) => Option.isSome(tokens), "[CustomHttpClient]: No auth tokens stored")
        )

        return request.pipe(
          HttpClientRequest.bearerToken(tokens.value.accessToken),
          HttpClientRequest.prependUrl("https://...")
        )
      })),
      HttpClient.filterStatusOk,
      HttpClient.catchTags({
        ResponseError: (error) => error.response.status === 401 ? session.refresh : Effect.fail(error)
      }),
      HttpClient.retry({
        times: 1,
        while: (error) => error._tag === "ForceRetryError"
      }),
      HttpClient.catchTags({
        ForceRetryError: Effect.die,
        NoAuthTokensStoredError: Effect.die
      })
    )
  })
}) {}

const program = Effect.gen(function*() {
  const client = yield* CustomHttpClient

  yield* client.get("...")
}).pipe(
  Effect.provide(CustomHttpClient.Default)
)
@thedevtoni
Copy link

Hey @lucas-barake. Great youtube channel and repositories. Great resources to learn about Effects.

Any chance you are looking for a job? I'm looking for engineers with experience in Effects for my startup. Let me know :)

@lucas-barake
Copy link
Author

Hey @lucas-barake. Great youtube channel and repositories. Great resources to learn about Effects.

Any chance you are looking for a job? I'm looking for engineers with experience in Effects for my startup. Let me know :)

Hey @thedevtoni thanks for the kind words 🙂

Totally missed this comment, my bad! Definitely interested. Feel free to shoot me an email at lucasbarakep at gmail.com.

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