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)
)
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 :)