From c27bfd1f77d3d6e4d36d5997d66fbfc9621e5498 Mon Sep 17 00:00:00 2001 From: Rusko124 Date: Mon, 22 Jun 2026 16:20:59 +0400 Subject: [PATCH 1/4] fix(powerbi): recover from transient FailedToLoadModel errors --- edge-apps/powerbi/src/services.lib.ts | 27 ++++++++++++ edge-apps/powerbi/src/services.test.ts | 58 +++++++++++++++++++++---- edge-apps/powerbi/src/services.ts | 53 ++++++++++++++-------- edge-apps/powerbi/src/services.types.ts | 1 + 4 files changed, 114 insertions(+), 25 deletions(-) diff --git a/edge-apps/powerbi/src/services.lib.ts b/edge-apps/powerbi/src/services.lib.ts index de0de3b7d..34c20b59c 100644 --- a/edge-apps/powerbi/src/services.lib.ts +++ b/edge-apps/powerbi/src/services.lib.ts @@ -3,6 +3,33 @@ import type { PowerBiError } from './services.types' export const DASHBOARD_READY_DELAY_MS = 1000 +// Power BI's "FailedToLoadModel" is transient (a stale/evicted dataset session); reload +// the report a few times to recover before giving up and showing the error screen. +const MODEL_LOAD_ERROR = 'FailedToLoadModel' +export const MAX_MODEL_RELOADS = 3 + +export function isModelLoadError(error: PowerBiError): boolean { + return (error.message ?? '').includes(MODEL_LOAD_ERROR) +} + +// Build a real Error (so Sentry groups/titles it) and flatten errorInfo to a string (so +// Sentry's normalizeDepth doesn't truncate the nested array to "[Array]"). +export function toReportableError(error: PowerBiError): Error { + return new Error( + error.message ?? error.detailedMessage ?? 'Power BI embed error', + ) +} + +export function powerBiErrorContext( + error: PowerBiError, +): Record { + return { + source: 'powerbi-embed', + detailedMessage: error.detailedMessage, + errorInfo: JSON.stringify(error.technicalDetails?.errorInfo ?? null), + } +} + let embedService: service.Service | undefined export function getEmbedService(): service.Service { diff --git a/edge-apps/powerbi/src/services.test.ts b/edge-apps/powerbi/src/services.test.ts index 22e2937a3..83154126b 100644 --- a/edge-apps/powerbi/src/services.test.ts +++ b/edge-apps/powerbi/src/services.test.ts @@ -46,7 +46,12 @@ function setupDom() { const embedCalls: Array<{ config: Record }> = [] const reportOn = mock(() => {}) const reportSetAccessToken = mock(async () => {}) -const fakeReport = { on: reportOn, setAccessToken: reportSetAccessToken } +const reportReload = mock(async () => {}) +const fakeReport = { + on: reportOn, + setAccessToken: reportSetAccessToken, + reload: reportReload, +} class FakeService { embed(_container: unknown, config: Record) { @@ -245,11 +250,14 @@ describe('services', () => { }) }) + // eslint-disable-next-line max-lines-per-function describe('initializePowerBI', () => { beforeEach(() => { embedCalls.length = 0 reportOn.mockClear() reportSetAccessToken.mockClear() + reportReload.mockClear() + signalReady.mockClear() setupDom() }) @@ -264,8 +272,13 @@ describe('services', () => { tokenType: 'Embed', permissions: 'All', }) - expect(reportOn).toHaveBeenCalledWith('rendered', signalReady) expect(report).toBe(fakeReport) + + const renderedHandler = reportOn.mock.calls.find( + (call) => call[0] === 'rendered', + )?.[1] as () => void + renderedHandler() + expect(signalReady).toHaveBeenCalled() }) it('when token retrieval fails, should report, render error, and rethrow', async () => { @@ -284,23 +297,52 @@ describe('services', () => { ) }) - it('when embed fires error event, should report it to sentry and render error', async () => { + async function embedAndGetErrorHandler() { setScreenly({ embed_token: 'static-token', embed_url: REPORT_EMBED_URL }) await initializePowerBI() - const errorHandler = reportOn.mock.calls.find( + return reportOn.mock.calls.find( (call) => call[0] === 'error', )?.[1] as (event: { detail: unknown }) => void + } + + it('when embed fires non-model error, should report it and render error', async () => { + const errorHandler = await embedAndGetErrorHandler() errorHandler({ detail: { detailedMessage: 'TokenExpired' } }) - expect(reportError).toHaveBeenCalledWith( - { detailedMessage: 'TokenExpired' }, - { source: 'powerbi-embed' }, - ) + const [reportedError, context] = reportError.mock.calls[0] as [ + Error, + Record, + ] + expect(reportedError.message).toBe('TokenExpired') + expect(context.source).toBe('powerbi-embed') + expect(reportReload).not.toHaveBeenCalled() expect(document.querySelector('.error-message')?.textContent).toBe( 'TokenExpired', ) }) + + it('when embed fires model-load error, should reload instead of showing error', async () => { + const errorHandler = await embedAndGetErrorHandler() + + errorHandler({ detail: { message: 'X_FailedToLoadModel_Y' } }) + + expect(reportReload).toHaveBeenCalledTimes(1) + expect(document.querySelector('.error-message')).toBeNull() + }) + + it('when model-load errors exceed max reloads, should show error', async () => { + const errorHandler = await embedAndGetErrorHandler() + const modelError = { detail: { message: 'X_FailedToLoadModel_Y' } } + + errorHandler(modelError) + errorHandler(modelError) + errorHandler(modelError) + errorHandler(modelError) + + expect(reportReload).toHaveBeenCalledTimes(3) + expect(document.querySelector('.error-container')).not.toBeNull() + }) }) }) diff --git a/edge-apps/powerbi/src/services.ts b/edge-apps/powerbi/src/services.ts index b057d3492..0c8626eaa 100644 --- a/edge-apps/powerbi/src/services.ts +++ b/edge-apps/powerbi/src/services.ts @@ -7,8 +7,12 @@ import { } from './utils' import { DASHBOARD_READY_DELAY_MS, + MAX_MODEL_RELOADS, getEmbedService, + isModelLoadError, + powerBiErrorContext, showError, + toReportableError, } from './services.lib' import type { EmbedToken, PowerBiError } from './services.types' import { reportError } from '@screenly/edge-apps/utils' @@ -95,24 +99,31 @@ export async function initializePowerBI(): Promise { throw error } - const report = getEmbedService().embed( - document.getElementById('embed-container') as HTMLElement, - { - embedUrl: embedUrl, - accessToken: initialToken.token, - type: resourceType, - tokenType: models.TokenType.Embed, - permissions: models.Permissions.All, - settings: { - filterPaneEnabled: false, - navContentPaneEnabled: false, - hideErrors: true, - }, + const container = document.getElementById('embed-container') as HTMLElement + const report = getEmbedService().embed(container, { + embedUrl: embedUrl, + accessToken: initialToken.token, + type: resourceType, + tokenType: models.TokenType.Embed, + permissions: models.Permissions.All, + settings: { + filterPaneEnabled: false, + navContentPaneEnabled: false, + hideErrors: true, }, - ) + }) + + // powerbi-client also dispatches a DOM 'error' CustomEvent that bubbles to window, where + // Sentry's global handler double-captures it; we report it explicitly below instead. + container.addEventListener('error', (event) => event.stopPropagation()) + + let modelReloadAttempts = 0 if (resourceType === 'report') { - report.on('rendered', screenly.signalReadyForRendering) + report.on('rendered', () => { + modelReloadAttempts = 0 + screenly.signalReadyForRendering() + }) } else if (resourceType === 'dashboard') { report.on('loaded', () => { setTimeout(screenly.signalReadyForRendering, DASHBOARD_READY_DELAY_MS) @@ -120,8 +131,16 @@ export async function initializePowerBI(): Promise { } report.on('error', function (event) { - reportError(event.detail, { source: 'powerbi-embed' }) - showError(event.detail as PowerBiError) + const detail = event.detail as PowerBiError + reportError(toReportableError(detail), powerBiErrorContext(detail)) + + if (isModelLoadError(detail) && modelReloadAttempts < MAX_MODEL_RELOADS) { + modelReloadAttempts += 1 + void report.reload() + return + } + + showError(detail) }) if (!screenly.settings.embed_token) { diff --git a/edge-apps/powerbi/src/services.types.ts b/edge-apps/powerbi/src/services.types.ts index 2791251a9..e1e01640c 100644 --- a/edge-apps/powerbi/src/services.types.ts +++ b/edge-apps/powerbi/src/services.types.ts @@ -4,6 +4,7 @@ export interface EmbedToken { } export interface PowerBiError { + message?: string detailedMessage?: string technicalDetails?: { errorInfo?: Array<{ key: string; value: string | number | undefined }> From b739d37fc144b25ec2d35197c53c1f1ac049f112 Mon Sep 17 00:00:00 2001 From: Rusko124 Date: Mon, 22 Jun 2026 16:31:52 +0400 Subject: [PATCH 2/4] fix(powerbi): enhance error handling and reporting for reload failures - Updated error reporting to include detailed messages in Sentry. - Improved the display of error messages in the UI when reloads fail. - Added tests to verify the correct rendering of error messages. --- edge-apps/powerbi/src/services.lib.ts | 10 ++++++---- edge-apps/powerbi/src/services.test.ts | 23 +++++++++++++++++++++++ edge-apps/powerbi/src/services.ts | 5 ++++- 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/edge-apps/powerbi/src/services.lib.ts b/edge-apps/powerbi/src/services.lib.ts index 34c20b59c..47e67c8a5 100644 --- a/edge-apps/powerbi/src/services.lib.ts +++ b/edge-apps/powerbi/src/services.lib.ts @@ -12,14 +12,15 @@ export function isModelLoadError(error: PowerBiError): boolean { return (error.message ?? '').includes(MODEL_LOAD_ERROR) } -// Build a real Error (so Sentry groups/titles it) and flatten errorInfo to a string (so -// Sentry's normalizeDepth doesn't truncate the nested array to "[Array]"). +// Build a real Error (so Sentry groups/titles it instead of "Object captured as exception"). export function toReportableError(error: PowerBiError): Error { return new Error( error.message ?? error.detailedMessage ?? 'Power BI embed error', ) } +// Flatten errorInfo to a string so Sentry's normalizeDepth doesn't truncate the nested +// array to "[Array]". export function powerBiErrorContext( error: PowerBiError, ): Record { @@ -51,8 +52,9 @@ export function showError(error: PowerBiError): void { const content = template.content.cloneNode(true) as DocumentFragment const messageEl = content.querySelector('.error-message') as HTMLElement - if (error.detailedMessage) { - messageEl.textContent = error.detailedMessage + const message = error.detailedMessage ?? error.message + if (message) { + messageEl.textContent = message } const table = content.querySelector('.error-details') as HTMLElement diff --git a/edge-apps/powerbi/src/services.test.ts b/edge-apps/powerbi/src/services.test.ts index 83154126b..339894ad0 100644 --- a/edge-apps/powerbi/src/services.test.ts +++ b/edge-apps/powerbi/src/services.test.ts @@ -331,6 +331,21 @@ describe('services', () => { expect(document.querySelector('.error-message')).toBeNull() }) + it('when reload fails, should report it and show error', async () => { + const errorHandler = await embedAndGetErrorHandler() + reportReload.mockImplementationOnce(async () => { + throw new Error('reload failed') + }) + + errorHandler({ detail: { message: 'X_FailedToLoadModel_Y' } }) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(reportError).toHaveBeenCalledWith(expect.any(Error), { + source: 'powerbi-reload', + }) + expect(document.querySelector('.error-container')).not.toBeNull() + }) + it('when model-load errors exceed max reloads, should show error', async () => { const errorHandler = await embedAndGetErrorHandler() const modelError = { detail: { message: 'X_FailedToLoadModel_Y' } } @@ -371,5 +386,13 @@ describe('services.lib', () => { expect(document.querySelector('.error-value')?.textContent).toBe('403') expect(signalReady).toHaveBeenCalled() }) + + it('when only message present, should render message', () => { + showError({ message: 'X_FailedToLoadModel_Y' }) + + expect(document.querySelector('.error-message')?.textContent).toBe( + 'X_FailedToLoadModel_Y', + ) + }) }) }) diff --git a/edge-apps/powerbi/src/services.ts b/edge-apps/powerbi/src/services.ts index 0c8626eaa..3248ab8b9 100644 --- a/edge-apps/powerbi/src/services.ts +++ b/edge-apps/powerbi/src/services.ts @@ -136,7 +136,10 @@ export async function initializePowerBI(): Promise { if (isModelLoadError(detail) && modelReloadAttempts < MAX_MODEL_RELOADS) { modelReloadAttempts += 1 - void report.reload() + report.reload().catch((reloadError) => { + reportError(reloadError, { source: 'powerbi-reload' }) + showError(detail) + }) return } From 5166b74faafbf92eb590a7c6b60aeeb214ed18e3 Mon Sep 17 00:00:00 2001 From: Rusko124 Date: Mon, 22 Jun 2026 16:55:15 +0400 Subject: [PATCH 3/4] fix(powerbi): implement retry logic for transient model load errors - Introduced a delay mechanism for retrying failed model loads to improve recovery from transient errors. - Updated error handling to ensure that reload attempts are properly counted and managed. - Enhanced tests to validate the new retry behavior and ensure correct error reporting. --- edge-apps/powerbi/src/services.lib.ts | 5 ++++- edge-apps/powerbi/src/services.test.ts | 31 +++++++++++++++++++++++--- edge-apps/powerbi/src/services.ts | 28 +++++++++++++++++------ 3 files changed, 53 insertions(+), 11 deletions(-) diff --git a/edge-apps/powerbi/src/services.lib.ts b/edge-apps/powerbi/src/services.lib.ts index 47e67c8a5..07894fbf8 100644 --- a/edge-apps/powerbi/src/services.lib.ts +++ b/edge-apps/powerbi/src/services.lib.ts @@ -4,9 +4,12 @@ import type { PowerBiError } from './services.types' export const DASHBOARD_READY_DELAY_MS = 1000 // Power BI's "FailedToLoadModel" is transient (a stale/evicted dataset session); reload -// the report a few times to recover before giving up and showing the error screen. +// the report a few times to recover before giving up and showing the error screen. The +// delay gives the dataset time to come back and clears reload()'s 100ms throttle, so an +// immediate retry isn't silently dropped. const MODEL_LOAD_ERROR = 'FailedToLoadModel' export const MAX_MODEL_RELOADS = 3 +export const MODEL_RELOAD_DELAY_MS = 5000 export function isModelLoadError(error: PowerBiError): boolean { return (error.message ?? '').includes(MODEL_LOAD_ERROR) diff --git a/edge-apps/powerbi/src/services.test.ts b/edge-apps/powerbi/src/services.test.ts index 339894ad0..eb7f1fcf6 100644 --- a/edge-apps/powerbi/src/services.test.ts +++ b/edge-apps/powerbi/src/services.test.ts @@ -252,15 +252,34 @@ describe('services', () => { // eslint-disable-next-line max-lines-per-function describe('initializePowerBI', () => { + let scheduled: Array<() => void> + let originalSetTimeout: typeof setTimeout + beforeEach(() => { embedCalls.length = 0 reportOn.mockClear() reportSetAccessToken.mockClear() reportReload.mockClear() signalReady.mockClear() + scheduled = [] + originalSetTimeout = globalThis.setTimeout + globalThis.setTimeout = ((fn: () => void) => { + scheduled.push(fn) + return 0 + }) as unknown as typeof setTimeout setupDom() }) + afterEach(() => { + globalThis.setTimeout = originalSetTimeout + }) + + function runScheduledReloads() { + const pending = scheduled.slice() + scheduled.length = 0 + pending.forEach((fn) => fn()) + } + it('when embedding report, should embed with view permissions and return report', async () => { setScreenly({ embed_token: 'static-token', embed_url: REPORT_EMBED_URL }) @@ -326,24 +345,29 @@ describe('services', () => { const errorHandler = await embedAndGetErrorHandler() errorHandler({ detail: { message: 'X_FailedToLoadModel_Y' } }) + runScheduledReloads() expect(reportReload).toHaveBeenCalledTimes(1) expect(document.querySelector('.error-message')).toBeNull() }) - it('when reload fails, should report it and show error', async () => { + it('when reload request fails and attempts remain, should retry instead of showing error', async () => { const errorHandler = await embedAndGetErrorHandler() reportReload.mockImplementationOnce(async () => { throw new Error('reload failed') }) errorHandler({ detail: { message: 'X_FailedToLoadModel_Y' } }) - await new Promise((resolve) => setTimeout(resolve, 0)) + runScheduledReloads() + await new Promise((resolve) => originalSetTimeout(resolve, 0)) expect(reportError).toHaveBeenCalledWith(expect.any(Error), { source: 'powerbi-reload', }) - expect(document.querySelector('.error-container')).not.toBeNull() + expect(document.querySelector('.error-container')).toBeNull() + + runScheduledReloads() + expect(reportReload).toHaveBeenCalledTimes(2) }) it('when model-load errors exceed max reloads, should show error', async () => { @@ -354,6 +378,7 @@ describe('services', () => { errorHandler(modelError) errorHandler(modelError) errorHandler(modelError) + runScheduledReloads() expect(reportReload).toHaveBeenCalledTimes(3) expect(document.querySelector('.error-container')).not.toBeNull() diff --git a/edge-apps/powerbi/src/services.ts b/edge-apps/powerbi/src/services.ts index 3248ab8b9..95e31ffaa 100644 --- a/edge-apps/powerbi/src/services.ts +++ b/edge-apps/powerbi/src/services.ts @@ -8,6 +8,7 @@ import { import { DASHBOARD_READY_DELAY_MS, MAX_MODEL_RELOADS, + MODEL_RELOAD_DELAY_MS, getEmbedService, isModelLoadError, powerBiErrorContext, @@ -119,6 +120,23 @@ export async function initializePowerBI(): Promise { let modelReloadAttempts = 0 + // Both failure paths funnel here so they share one budget: a failed reload re-enters + // and counts as an attempt, just like a fresh model-load error event does. + function reloadOrShowError(detail: PowerBiError) { + if (modelReloadAttempts >= MAX_MODEL_RELOADS) { + showError(detail) + return + } + + modelReloadAttempts += 1 + setTimeout(() => { + report.reload().catch((reloadError) => { + reportError(reloadError, { source: 'powerbi-reload' }) + reloadOrShowError(detail) + }) + }, MODEL_RELOAD_DELAY_MS) + } + if (resourceType === 'report') { report.on('rendered', () => { modelReloadAttempts = 0 @@ -130,16 +148,12 @@ export async function initializePowerBI(): Promise { }) } - report.on('error', function (event) { + report.on('error', (event) => { const detail = event.detail as PowerBiError reportError(toReportableError(detail), powerBiErrorContext(detail)) - if (isModelLoadError(detail) && modelReloadAttempts < MAX_MODEL_RELOADS) { - modelReloadAttempts += 1 - report.reload().catch((reloadError) => { - reportError(reloadError, { source: 'powerbi-reload' }) - showError(detail) - }) + if (isModelLoadError(detail)) { + reloadOrShowError(detail) return } From 3c3bffdfae5568e15ab8a6edf3315e0fb962b36a Mon Sep 17 00:00:00 2001 From: Rusko124 Date: Mon, 22 Jun 2026 17:09:03 +0400 Subject: [PATCH 4/4] fix(powerbi): improve error handling and reload logic for model-load failures - Added a default error message for better user feedback when loading reports fails. - Refactored the reload logic to prevent duplicate reload attempts and ensure proper error handling. - Enhanced tests to validate the new behavior of error handling and reload scheduling. --- edge-apps/powerbi/src/services.lib.ts | 8 ++-- edge-apps/powerbi/src/services.test.ts | 49 +++++++++++++++++--- edge-apps/powerbi/src/services.ts | 62 +++++++++++++++++--------- 3 files changed, 88 insertions(+), 31 deletions(-) diff --git a/edge-apps/powerbi/src/services.lib.ts b/edge-apps/powerbi/src/services.lib.ts index 07894fbf8..6263b1806 100644 --- a/edge-apps/powerbi/src/services.lib.ts +++ b/edge-apps/powerbi/src/services.lib.ts @@ -45,6 +45,8 @@ export function getEmbedService(): service.Service { return embedService } +const DEFAULT_ERROR_MESSAGE = 'Unable to load report' + export function showError(error: PowerBiError): void { const container = document.getElementById('embed-container') as HTMLElement container.innerHTML = '' @@ -55,10 +57,8 @@ export function showError(error: PowerBiError): void { const content = template.content.cloneNode(true) as DocumentFragment const messageEl = content.querySelector('.error-message') as HTMLElement - const message = error.detailedMessage ?? error.message - if (message) { - messageEl.textContent = message - } + messageEl.textContent = + error.detailedMessage ?? error.message ?? DEFAULT_ERROR_MESSAGE const table = content.querySelector('.error-details') as HTMLElement const rowTemplate = document.getElementById( diff --git a/edge-apps/powerbi/src/services.test.ts b/edge-apps/powerbi/src/services.test.ts index eb7f1fcf6..ecbd9ea18 100644 --- a/edge-apps/powerbi/src/services.test.ts +++ b/edge-apps/powerbi/src/services.test.ts @@ -252,8 +252,10 @@ describe('services', () => { // eslint-disable-next-line max-lines-per-function describe('initializePowerBI', () => { - let scheduled: Array<() => void> + let scheduled: Map void> + let nextTimerId: number let originalSetTimeout: typeof setTimeout + let originalClearTimeout: typeof clearTimeout beforeEach(() => { embedCalls.length = 0 @@ -261,22 +263,29 @@ describe('services', () => { reportSetAccessToken.mockClear() reportReload.mockClear() signalReady.mockClear() - scheduled = [] + scheduled = new Map() + nextTimerId = 1 originalSetTimeout = globalThis.setTimeout + originalClearTimeout = globalThis.clearTimeout globalThis.setTimeout = ((fn: () => void) => { - scheduled.push(fn) - return 0 + const id = nextTimerId++ + scheduled.set(id, fn) + return id }) as unknown as typeof setTimeout + globalThis.clearTimeout = ((id: number) => { + scheduled.delete(id) + }) as unknown as typeof clearTimeout setupDom() }) afterEach(() => { globalThis.setTimeout = originalSetTimeout + globalThis.clearTimeout = originalClearTimeout }) function runScheduledReloads() { - const pending = scheduled.slice() - scheduled.length = 0 + const pending = [...scheduled.values()] + scheduled.clear() pending.forEach((fn) => fn()) } @@ -370,15 +379,41 @@ describe('services', () => { expect(reportReload).toHaveBeenCalledTimes(2) }) - it('when model-load errors exceed max reloads, should show error', async () => { + it('when duplicate errors fire before reload, should schedule only one reload', async () => { const errorHandler = await embedAndGetErrorHandler() const modelError = { detail: { message: 'X_FailedToLoadModel_Y' } } errorHandler(modelError) errorHandler(modelError) + runScheduledReloads() + + expect(reportReload).toHaveBeenCalledTimes(1) + }) + + it('when report renders before reload fires, should cancel pending reload', async () => { + const errorHandler = await embedAndGetErrorHandler() + const renderedHandler = reportOn.mock.calls.find( + (call) => call[0] === 'rendered', + )?.[1] as () => void + + errorHandler({ detail: { message: 'X_FailedToLoadModel_Y' } }) + renderedHandler() + runScheduledReloads() + + expect(reportReload).not.toHaveBeenCalled() + }) + + it('when model-load errors exceed max reloads, should show error', async () => { + const errorHandler = await embedAndGetErrorHandler() + const modelError = { detail: { message: 'X_FailedToLoadModel_Y' } } + + errorHandler(modelError) + runScheduledReloads() errorHandler(modelError) + runScheduledReloads() errorHandler(modelError) runScheduledReloads() + errorHandler(modelError) expect(reportReload).toHaveBeenCalledTimes(3) expect(document.querySelector('.error-container')).not.toBeNull() diff --git a/edge-apps/powerbi/src/services.ts b/edge-apps/powerbi/src/services.ts index 95e31ffaa..82e3b32c1 100644 --- a/edge-apps/powerbi/src/services.ts +++ b/edge-apps/powerbi/src/services.ts @@ -81,6 +81,45 @@ export function initTokenRefreshLoop( ) } +// Drives model-load recovery. Both a fresh error event and a failed reload funnel through +// reloadOrShowError so they share one retry budget. While a reload is pending, further +// errors are ignored so duplicates don't stack overlapping reloads. A successful render +// calls reset(), cancelling any reload still waiting on its delay. +function createReloadController(report: Embed) { + let attempts = 0 + let timer: ReturnType | undefined + + function reset() { + if (timer !== undefined) { + clearTimeout(timer) + timer = undefined + } + attempts = 0 + } + + function reloadOrShowError(detail: PowerBiError) { + if (timer !== undefined) { + return + } + + if (attempts >= MAX_MODEL_RELOADS) { + showError(detail) + return + } + + attempts += 1 + timer = setTimeout(() => { + timer = undefined + report.reload().catch((reloadError) => { + reportError(reloadError, { source: 'powerbi-reload' }) + reloadOrShowError(detail) + }) + }, MODEL_RELOAD_DELAY_MS) + } + + return { reset, reloadOrShowError } +} + export async function initializePowerBI(): Promise { const embedUrl = screenly.settings.embed_url const resourceType = getEmbedTypeFromUrl(embedUrl) @@ -118,28 +157,11 @@ export async function initializePowerBI(): Promise { // Sentry's global handler double-captures it; we report it explicitly below instead. container.addEventListener('error', (event) => event.stopPropagation()) - let modelReloadAttempts = 0 - - // Both failure paths funnel here so they share one budget: a failed reload re-enters - // and counts as an attempt, just like a fresh model-load error event does. - function reloadOrShowError(detail: PowerBiError) { - if (modelReloadAttempts >= MAX_MODEL_RELOADS) { - showError(detail) - return - } - - modelReloadAttempts += 1 - setTimeout(() => { - report.reload().catch((reloadError) => { - reportError(reloadError, { source: 'powerbi-reload' }) - reloadOrShowError(detail) - }) - }, MODEL_RELOAD_DELAY_MS) - } + const reloadController = createReloadController(report) if (resourceType === 'report') { report.on('rendered', () => { - modelReloadAttempts = 0 + reloadController.reset() screenly.signalReadyForRendering() }) } else if (resourceType === 'dashboard') { @@ -153,7 +175,7 @@ export async function initializePowerBI(): Promise { reportError(toReportableError(detail), powerBiErrorContext(detail)) if (isModelLoadError(detail)) { - reloadOrShowError(detail) + reloadController.reloadOrShowError(detail) return }