Skip to content

Commit c619cae

Browse files
authored
fix(account): coalesce concurrent console token refreshes (#20503)
1 parent c559af5 commit c619cae

2 files changed

Lines changed: 90 additions & 3 deletions

File tree

packages/opencode/src/account/index.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
1+
import { Cache, Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
22
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
33

44
import { makeRuntime } from "@/effect/run-service"
@@ -175,9 +175,8 @@ export namespace Account {
175175
mapAccountServiceError("HTTP request failed"),
176176
)
177177

178-
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
178+
const refreshToken = Effect.fnUntraced(function* (row: AccountRow) {
179179
const now = yield* Clock.currentTimeMillis
180-
if (row.token_expiry && row.token_expiry > now) return row.access_token
181180

182181
const response = yield* executeEffectOk(
183182
HttpClientRequest.post(`${row.url}/auth/device/token`).pipe(
@@ -208,6 +207,30 @@ export namespace Account {
208207
return parsed.access_token
209208
})
210209

210+
const refreshTokenCache = yield* Cache.make<AccountID, AccessToken, AccountError>({
211+
capacity: Number.POSITIVE_INFINITY,
212+
timeToLive: Duration.zero,
213+
lookup: Effect.fnUntraced(function* (accountID) {
214+
const maybeAccount = yield* repo.getRow(accountID)
215+
if (Option.isNone(maybeAccount)) {
216+
return yield* Effect.fail(new AccountServiceError({ message: "Account not found during token refresh" }))
217+
}
218+
219+
const account = maybeAccount.value
220+
const now = yield* Clock.currentTimeMillis
221+
if (account.token_expiry && account.token_expiry > now) return account.access_token
222+
223+
return yield* refreshToken(account)
224+
}),
225+
})
226+
227+
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
228+
const now = yield* Clock.currentTimeMillis
229+
if (row.token_expiry && row.token_expiry > now) return row.access_token
230+
231+
return yield* Cache.get(refreshTokenCache, row.id)
232+
})
233+
211234
const resolveAccess = Effect.fnUntraced(function* (accountID: AccountID) {
212235
const maybeAccount = yield* repo.getRow(accountID)
213236
if (Option.isNone(maybeAccount)) return Option.none()

packages/opencode/test/account/service.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,70 @@ it.live("token refresh persists the new token", () =>
148148
}),
149149
)
150150

151+
it.live("concurrent config and token requests coalesce token refresh", () =>
152+
Effect.gen(function* () {
153+
const id = AccountID.make("user-1")
154+
155+
yield* AccountRepo.use((r) =>
156+
r.persistAccount({
157+
id,
158+
email: "user@example.com",
159+
url: "https://one.example.com",
160+
accessToken: AccessToken.make("at_old"),
161+
refreshToken: RefreshToken.make("rt_old"),
162+
expiry: Date.now() - 1_000,
163+
orgID: Option.some(OrgID.make("org-9")),
164+
}),
165+
)
166+
167+
let refreshCalls = 0
168+
const client = HttpClient.make((req) =>
169+
Effect.promise(async () => {
170+
if (req.url === "https://one.example.com/auth/device/token") {
171+
refreshCalls += 1
172+
173+
if (refreshCalls === 1) {
174+
await new Promise((resolve) => setTimeout(resolve, 25))
175+
return json(req, {
176+
access_token: "at_new",
177+
refresh_token: "rt_new",
178+
expires_in: 60,
179+
})
180+
}
181+
182+
return json(
183+
req,
184+
{
185+
error: "invalid_grant",
186+
error_description: "refresh token already used",
187+
},
188+
400,
189+
)
190+
}
191+
192+
if (req.url === "https://one.example.com/api/config") {
193+
return json(req, { config: { theme: "light", seats: 5 } })
194+
}
195+
196+
return json(req, {}, 404)
197+
}),
198+
)
199+
200+
const [cfg, token] = yield* Account.Service.use((s) =>
201+
Effect.all([s.config(id, OrgID.make("org-9")), s.token(id)], { concurrency: 2 }),
202+
).pipe(Effect.provide(live(client)))
203+
204+
expect(Option.getOrThrow(cfg)).toEqual({ theme: "light", seats: 5 })
205+
expect(String(Option.getOrThrow(token))).toBe("at_new")
206+
expect(refreshCalls).toBe(1)
207+
208+
const row = yield* AccountRepo.use((r) => r.getRow(id))
209+
const value = Option.getOrThrow(row)
210+
expect(value.access_token).toBe(AccessToken.make("at_new"))
211+
expect(value.refresh_token).toBe(RefreshToken.make("rt_new"))
212+
}),
213+
)
214+
151215
it.live("config sends the selected org header", () =>
152216
Effect.gen(function* () {
153217
const id = AccountID.make("user-1")

0 commit comments

Comments
 (0)