Skip to content

Commit

Permalink
feature: resilience: handle session not found and get session errors
Browse files Browse the repository at this point in the history
  • Loading branch information
halimath committed May 10, 2024
1 parent 359a44a commit df8e410
Show file tree
Hide file tree
Showing 4 changed files with 106 additions and 36 deletions.
2 changes: 1 addition & 1 deletion app/public/messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"player.spendFatePoint.powerStunt": "Einen Stunt bezahlen",
"player.spendFatePoint.refuseCompel": "Reizen ablehnen",
"player.spendFatePoint.storyDetail": "Ein Detail hinzufügen",
"sessionClosed.message": "Der Spielleiter hat den Tisch verlassen.",
"sessionClosed.message": "Die Sitzung wurde beendet.",
"result.8": "Legendär (+8)",
"result.7": "Episch (+7)",
"result.6": "Fantastisch (+6)",
Expand Down
2 changes: 1 addition & 1 deletion app/public/messages/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"player.spendFatePoint.powerStunt": "Power a stunt",
"player.spendFatePoint.refuseCompel": "Refuse a compel",
"player.spendFatePoint.storyDetail": "Declare a story detail",
"sessionClosed.message": "The gamemaster closed the table.",
"sessionClosed.message": "The session has been closed.",
"result.8": "+8 legendary",
"result.7": "+7 epic",
"result.6": "+6 fantastic",
Expand Down
54 changes: 37 additions & 17 deletions app/src/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ApiClient, Session as SessionDto } from "../../generated"
import { ApiClient, ApiError, Session as SessionDto } from "../../generated"
import { Aspect, Player, Session } from "../models"

const AuthTokenSessionStorageKey = "auth-token"
Expand Down Expand Up @@ -40,18 +40,28 @@ export class GamemasterApi {
title: title,
}
})
return new GamemasterApi(apiClient, sessionId)

return new GamemasterApi(apiClient, sessionId)
}

constructor(
private readonly apiClient: ApiClient,
readonly sessionId: string,
) {}
) { }

async getSession(): Promise<Session | null> {
try {
let dto = await this.apiClient.session.getSession({ id: this.sessionId })
return convertTable(dto)
} catch (e) {
if (e instanceof ApiError) {
if (e.status === 404) {
return null
}
}

async getSession(): Promise<Session> {
let dto = await this.apiClient.session.getSession({ id: this.sessionId })
return convertTable(dto)
throw e
}
}

async updateFatePoints(characterId: string, delta: number) {
Expand Down Expand Up @@ -81,40 +91,50 @@ export class GamemasterApi {
name: name,
}
})
}
}
}

async removeAspect(id: string) {
await this.apiClient.session.deleteAspect({
id: this.sessionId,
aspectId: id,
})
})
}
}

export class PlayerCharacterApi {
static async joinGame(id: string, name: string): Promise<PlayerCharacterApi> {
const apiClient = await createApiClient()

const characterId = await apiClient.session.joinSession({
id: id,
requestBody: {
name: name,
}
})

return new PlayerCharacterApi(apiClient, id, characterId)
}

constructor(
private readonly apiClient: ApiClient,
readonly sessionId: string,
readonly characterId: string,
) {}
) { }

async getSession(): Promise<Session | null> {
try {
let dto = await this.apiClient.session.getSession({ id: this.sessionId })
return convertTable(dto, this.characterId)
} catch (e) {
if (e instanceof ApiError) {
if (e.status === 404) {
return null
}
}

async getSession(): Promise<Session> {
let dto = await this.apiClient.session.getSession({ id: this.sessionId })
return convertTable(dto, this.characterId)
throw e
}
}

async spendFatePoint() {
Expand All @@ -124,7 +144,7 @@ export class PlayerCharacterApi {
requestBody: {
fatePointsDelta: -1,
}
})
})
}
}

Expand Down
84 changes: 67 additions & 17 deletions app/src/control/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import { m } from "../utils/i18n"
export class ReplaceScene {
readonly command = "replace-scene"

constructor(public readonly scene: Scene) { }
public readonly notifications: Array<Notification>

constructor(public readonly scene: Scene, ...notifications: Array<Notification>) {
this.notifications = notifications
}
}

export class PostNotification {
Expand Down Expand Up @@ -77,12 +81,14 @@ export type Message = ReplaceScene |
export class Controller {
private api: GamemasterApi | PlayerCharacterApi | null = null

private updateInterval: number | null = null
private updateIntervalHandle: number | null = null
private inUpdate = false

private clearUpdate() {
if (this.updateInterval !== null) {
clearInterval(this.updateInterval)
if (this.updateIntervalHandle !== null) {
clearInterval(this.updateIntervalHandle)
}
this.inUpdate = false
}

private scheduleUpdates(emit: wecco.MessageEmitter<Message>) {
Expand All @@ -91,33 +97,53 @@ export class Controller {
return
}

const session = await this.api.getSession()

let scene: Scene
if (this.inUpdate) {
return
}

if (this.api instanceof GamemasterApi) {
scene = new GamemasterScene(session)
} else {
scene = new PlayerCharacterScene(session)
try {
this.inUpdate = true
const session = await withMaxRetries(this.api.getSession.bind(this.api))
if (session === null) {
// Session has been removed from the server.
emit(new SessionClosed())
return
}

let scene: Scene

if (this.api instanceof GamemasterApi) {
scene = new GamemasterScene(session)
} else {
scene = new PlayerCharacterScene(session)
}

emit(new ReplaceScene(scene))
} catch (e) {
console.log(`Got error while loading session: ${e}. Considering session closed.`)
emit(new SessionClosed())
} finally {
this.inUpdate = false
}

emit(new ReplaceScene(scene))
}

this.updateInterval = setInterval(requestAndEmitUpdate, 1000)
this.updateIntervalHandle = setInterval(requestAndEmitUpdate, 1000)
requestAndEmitUpdate()
}

async update({ model, message, emit }: wecco.UpdaterContext<Model, Message>): Promise<Model | typeof wecco.NoModelChange> {
switch (message.command) {
case "replace-scene":
return new Model(model.versionInfo, message.scene)
return new Model(model.versionInfo, message.scene, ...message.notifications)

case "post-notification":
return new Model(model.versionInfo, model.scene, ...message.notifications)

case "session-closed":
return new Model(model.versionInfo, new HomeScene(), new Notification(m("tableClosed.message")))
this.clearUpdate()
this.api = null
history.pushState(null, "", "/")
return new Model(model.versionInfo, new HomeScene(), new Notification(m("sessionClosed.message")))

case "new-session":
this.clearUpdate()
Expand Down Expand Up @@ -174,7 +200,6 @@ export class Controller {
}
}


async function rejoinSession (sessionId: string): Promise<GamemasterApi | PlayerCharacterApi> {
let apiClient = await createApiClient()

Expand All @@ -192,3 +217,28 @@ async function rejoinSession (sessionId: string): Promise<GamemasterApi | Player
return new PlayerCharacterApi(apiClient, sessionId, characterId!)
}
}

async function withMaxRetries<R>(fn: () => Promise<R>, maxTries=5, backOff=200): Promise<R> {
let tries = 1
let sleepTime = 0

while (true) {
try {
return await fn()
} catch (e) {
if (tries == maxTries) {
throw e
}
console.error(`Got error while executing call: ${e}. Retrying ${maxTries - tries} times...`)
tries++
sleepTime += backOff
await sleep(sleepTime)
}
}
}

function sleep (ms: number): Promise<void> {
return new Promise(resolve => {
setTimeout(resolve, ms)
})
}

0 comments on commit df8e410

Please sign in to comment.