Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 5 additions & 32 deletions packages/shared/src/lib/components/OpeyChat.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -523,37 +523,10 @@
await sessionController.init();
}

// Add retry logic with exponential backoff
// Retry with backoff lives in the controller: a logged-out visitor makes no request, and a
// refusal such as 401 is reported once with its cause instead of being retried.
async function initializeOpeySessionWithRetry(maxRetries = 3, baseDelay = 1000) {
let lastError: string | null = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
await initializeOpeySession();
if (session.status === 'ready') {
logger.debug(`Opey session initialized successfully on attempt ${attempt}`);
return;
}
// Init didn't throw but the session isn't ready — capture whatever
// session.error was set to internally so it isn't swallowed.
if (session.error) lastError = session.error;
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
logger.warn(`Session initialization attempt ${attempt} failed:`, error);
}

if (attempt < maxRetries) {
const delay = baseDelay * Math.pow(2, attempt - 1); // Exponential backoff
logger.debug(`Retrying session initialization in ${delay}ms...`);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}

// Surface the real underlying cause rather than just "after N attempts".
const finalMessage = lastError
? `Failed to initialize after ${maxRetries} attempts: ${lastError}`
: `Failed to initialize after ${maxRetries} attempts`;
logger.error(finalMessage);
sessionState.setStatus('error', finalMessage);
await sessionController.initWithRetry({ userAuthenticated, maxRetries, baseDelay });
}

/**
Expand Down Expand Up @@ -803,7 +776,7 @@
<button
class="text-s btn flex items-center rounded-lg border border-solid border-primary-500 bg-primary-50-950 px-3"
onclick={() => handleSendMessage(question.questionString)}
disabled={session?.status !== 'ready'}
disabled={session?.status !== 'ready' || !userAuthenticated}
>
{#if question.icon}
<question.icon />
Expand Down Expand Up @@ -1106,7 +1079,7 @@
: 'Ask me anything...'}
class="w-full resize-none border-none bg-transparent p-0.5 outline-none shadow-none focus:outline-none focus:shadow-none focus:ring-0 focus-visible:outline-none max-h-40 overflow-y-auto"
style="min-height: 2.5rem;"
disabled={session?.status !== 'ready'}
disabled={session?.status !== 'ready' || !userAuthenticated}
onkeydown={handleKeyPress}
oninput={autoResize}
rows="1"
Expand Down
51 changes: 50 additions & 1 deletion packages/shared/src/lib/opey/controllers/SessionController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { createLogger } from '../../utils/logger.js';
const logger = createLogger('SessionController');
import type { SessionService } from '../services/SessionService.js';
import type { SessionState } from '../state/SessionState.js';
import { isRetryableStatus } from '../errors.js';

export class SessionController {
constructor(
Expand All @@ -34,10 +35,58 @@ export class SessionController {
this.sessionState.setStatus('ready');
} catch (error: any) {
logger.error('Session init error:', error);
this.sessionState.setStatus('error', error.message);
this.sessionState.setStatus('error', error.message, error.status);
}
}

/**
* Start the session, retrying with exponential backoff.
*
* A logged-out visitor has nothing to authenticate with, so the auth route is not called at all
* (the chat asks them to log in). A refusal that will come out the same next time (a 4xx such as
* 401) is reported at once with its real cause instead of being retried.
*/
async initWithRetry(options: {
userAuthenticated: boolean;
maxRetries?: number;
baseDelay?: number;
sleep?: (ms: number) => Promise<void>;
}): Promise<void> {
const { userAuthenticated, maxRetries = 3, baseDelay = 1000 } = options;
const sleep = options.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));

if (!userAuthenticated) {
this.sessionState.setAuth(false);
this.sessionState.setStatus('ready');
return;
}

let lastError: string | undefined;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
await this.init();
const snapshot = this.sessionState.current;
if (snapshot.status === 'ready') {
logger.debug(`Opey session initialized successfully on attempt ${attempt}`);
return;
}
lastError = snapshot.error;
if (!isRetryableStatus(snapshot.errorStatus)) {
// Leave the refusal as it is: its message names the cause.
logger.warn(`Opey session refused (HTTP ${snapshot.errorStatus}): ${lastError}`);
return;
}
if (attempt < maxRetries) {
await sleep(baseDelay * Math.pow(2, attempt - 1));
}
}

const finalMessage = lastError
? `Failed to initialize after ${maxRetries} attempts: ${lastError}`
: `Failed to initialize after ${maxRetries} attempts`;
logger.error(finalMessage);
this.sessionState.setStatus('error', finalMessage);
}

async destroy(): Promise<void> {
this.sessionState.setStatus('loading');
await this.service.deleteSession();
Expand Down
40 changes: 40 additions & 0 deletions packages/shared/src/lib/opey/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* Copyright (C) 2025-2026 TESOBE GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/

/** A refusal while starting an Opey session; `statusCode` is what the auth route answers with. */
export class OpeyAuthError extends Error {
statusCode: number;

constructor(message: string, statusCode: number) {
super(message);
this.name = 'OpeyAuthError';
this.statusCode = statusCode;
}
}

/** The status the auth route returns for an Opey answer: a client error is passed on, anything else is a bad gateway. */
export function statusForOpeyAnswer(opeyStatus: number): number {
return opeyStatus >= 400 && opeyStatus < 500 ? opeyStatus : 502;
}

/** A 4xx other than request timeout / rate limit will answer the same way next time, so it is not retried. */
export function isRetryableStatus(status: number | undefined): boolean {
if (status === undefined) return true;
if (status === 408 || status === 429) return true;
return !(status >= 400 && status < 500);
}
196 changes: 196 additions & 0 deletions packages/shared/src/lib/opey/opeyAuthStatus.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
/*
* Copyright (C) 2025-2026 TESOBE GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* Opey session start-up: the auth route keeps Opey's status, the widget does not retry a refusal
* or call the route for a logged-out visitor, and a consent that is still INITIATED is not
* replaced by a new one. Lives outside src/lib/server so vitest picks it up.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createOpeyAuthHandler } from '../server/opey/handlers';
import { DefaultOBPIntegrationService } from '../server/obp/OBPIntegrationService';
import { OpeySessionService } from './services/OpeySessionService';
import { SessionController } from './controllers/SessionController';
import { SessionState } from './state/SessionState';

const OPEY = 'http://opey.test';

function opeyRefuses(status: number, detail: string) {
return vi.fn(async () => new Response(JSON.stringify({ detail }), { status }));
}

function authEvent() {
return { request: new Request('http://app.test/backend/opey/auth', { method: 'POST' }) } as any;
}

afterEach(() => {
vi.unstubAllGlobals();
});

describe('POST /backend/opey/auth keeps the status Opey answered with', () => {
it('answers 401, not 500, to a logged-out visitor when Opey wants credentials', async () => {
vi.stubGlobal('fetch', opeyRefuses(401, 'Missing Authorization headers'));
const { POST } = createOpeyAuthHandler({
opeyBaseUrl: OPEY,
getAccessToken: () => undefined,
getSession: () => undefined
});
const res = await POST(authEvent());
expect(res.status).toBe(401);
expect((await res.json()).message).toContain('Missing Authorization headers');
});

it('answers 401, not 500, when Opey says the consent is not ACCEPTED', async () => {
vi.stubGlobal('fetch', opeyRefuses(401, 'OBP-35005: Consent-Id is not in status ACCEPTED'));
const { POST } = createOpeyAuthHandler({
opeyBaseUrl: OPEY,
opeyConsumerId: 'opey-consumer',
getAccessToken: () => 'token',
getSession: () => ({}),
getOrCreateOpeyConsent: async () => ({ consent_id: 'c1', jwt: 'a.b.c' }) as any
});
const res = await POST(authEvent());
expect(res.status).toBe(401);
expect((await res.json()).message).toContain('OBP-35005');
});

it('does not blame an expired JWT when the cause is the consent status', async () => {
vi.stubGlobal('fetch', opeyRefuses(401, 'OBP-35005: Consent-Id is not in status ACCEPTED'));
const info = vi.spyOn(console, 'info').mockImplementation(() => {});
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
const { POST } = createOpeyAuthHandler({
opeyBaseUrl: OPEY,
opeyConsumerId: 'opey-consumer',
getAccessToken: () => 'token',
getSession: () => ({}),
getOrCreateOpeyConsent: async () => ({ consent_id: 'c1', jwt: 'a.b.c' }) as any
});
await POST(authEvent());
const logged = [...info.mock.calls, ...log.mock.calls].flat().join(' ');
expect(logged).not.toContain('JWT expired');
});
});

describe('the widget session start-up', () => {
function controllerFor(createSession: () => Promise<{ authenticated: boolean }>) {
const state = new SessionState();
const service = { createSession: vi.fn(createSession) } as any;
return { state, service, controller: new SessionController(service, state) };
}

it('does not call the route for a logged-out visitor', async () => {
const { service, controller, state } = controllerFor(async () => ({ authenticated: false }));
await controller.initWithRetry({ userAuthenticated: false, sleep: async () => {} });
expect(service.createSession).not.toHaveBeenCalled();
expect(state.current.isAuthenticated).toBe(false);
});

it('does not retry a 401 and reports the real cause', async () => {
const { service, controller, state } = controllerFor(async () => {
const err: any = new Error('OBP-35005: Consent-Id is not in status ACCEPTED');
err.status = 401;
throw err;
});
await controller.initWithRetry({ userAuthenticated: true, sleep: async () => {} });
expect(service.createSession).toHaveBeenCalledTimes(1);
expect(state.current.status).toBe('error');
expect(state.current.error).toContain('OBP-35005');
expect(state.current.error).not.toContain('attempts');
});

it('still retries a server error', async () => {
const { service, controller } = controllerFor(async () => {
const err: any = new Error('boom');
err.status = 502;
throw err;
});
await controller.initWithRetry({
userAuthenticated: true,
maxRetries: 3,
sleep: async () => {}
});
expect(service.createSession).toHaveBeenCalledTimes(3);
});

it('OpeySessionService carries the HTTP status on the error it throws', async () => {
vi.stubGlobal(
'fetch',
vi.fn(
async () => new Response(JSON.stringify({ message: 'nope', code: 401 }), { status: 401 })
)
);
await expect(
new OpeySessionService('/backend/opey/auth').createSession()
).rejects.toMatchObject({
status: 401,
message: 'nope'
});
});
});

describe('getOrCreateOpeyConsent with a consent that is not ACCEPTED', () => {
const session: any = { data: { oauth: { access_token: 'tok' } } };
const jwt = 'x.eyJwcmVmZXJyZWRfdXNlcm5hbWUiOiJ1In0.y';

function obpWith(consents: any[], created?: any) {
const post = vi.fn(async () => created);
const get = vi.fn(async (path: string) => {
if (path.endsWith('/my/consents')) return { consents };
throw new Error('not found');
});
return {
post,
get,
svc: new DefaultOBPIntegrationService('opey-consumer', { get, post } as any)
};
}

it('does not create another consent while one is INITIATED', async () => {
const { post, svc } = obpWith([
{
consent_id: 'c1',
consumer_id: 'opey-consumer',
status: 'INITIATED',
jwt,
jwt_expires_at: new Date(Date.now() + 3600_000).toISOString()
}
]);
await expect(svc.getOrCreateOpeyConsent(session)).rejects.toMatchObject({ statusCode: 401 });
expect(post).not.toHaveBeenCalled();
});

it('reports a freshly created consent that comes back INITIATED instead of handing it to Opey', async () => {
const { post, svc } = obpWith([], { consent_id: 'c2', status: 'INITIATED', jwt });
await expect(svc.getOrCreateOpeyConsent(session)).rejects.toMatchObject({ statusCode: 401 });
expect(post).toHaveBeenCalledTimes(1);
});

it('still returns an ACCEPTED consent from the list without creating one', async () => {
const { post, svc } = obpWith([
{
consent_id: 'c3',
consumer_id: 'opey-consumer',
status: 'ACCEPTED',
jwt,
jwt_expires_at: new Date(Date.now() + 3600_000).toISOString()
}
]);
const consent = await svc.getOrCreateOpeyConsent(session);
expect(consent.consent_id).toBe('c3');
expect(post).not.toHaveBeenCalled();
});
});
15 changes: 13 additions & 2 deletions packages/shared/src/lib/opey/services/OpeySessionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,17 @@ import type { SessionService } from './SessionService.js';
* the host app's server route, which is the only place that has
* access to the OAuth token.
*/
/** Session start-up refused by the auth route; `status` is its HTTP status. */
export class OpeySessionError extends Error {
status: number;

constructor(message: string, status: number) {
super(message);
this.name = 'OpeySessionError';
this.status = status;
}
}

export class OpeySessionService implements SessionService {
constructor(private authEndpoint: string = '/backend/opey/auth') {}

Expand All @@ -42,10 +53,10 @@ export class OpeySessionService implements SessionService {
credentials: 'include'
});

const data = await response.json();
const data = await response.json().catch(() => ({}));

if (!response.ok) {
throw new Error(data.message ?? `HTTP ${response.status}`);
throw new OpeySessionError(data.message ?? `HTTP ${response.status}`, response.status);
}

logger.info(`Opey session created - authenticated: ${data.authenticated}`);
Expand Down
Loading
Loading