Skip to content
Merged
2 changes: 1 addition & 1 deletion .github/workflows/pr-all-checks-passed.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ jobs:
checks: read
contents: read
steps:
- uses: wechuli/allcheckspassed@2e5e8bbc775f5680ed5d02e3a22e2fc7219792ac
- uses: wechuli/allcheckspassed@v1.2.0
with:
retries: '40'
polling_interval: '1'
4 changes: 2 additions & 2 deletions backend/common.js
Original file line number Diff line number Diff line change
Expand Up @@ -143,10 +143,10 @@ export const makeHandleRequest = () => {
};
};

// Rate limiter: Max 100 requests per 1 minutes per IP
// Rate limiter: Max 200 requests per 1 minutes per IP
const limiter = rateLimit({
windowMs: 1 * 60 * 1000,
max: 100,
max: 200,
message: 'Too many requests from this IP, please try again later.',
standardHeaders: true,
legacyHeaders: false,
Expand Down
1 change: 1 addition & 0 deletions backend/companion/companionRouter.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ async function handleChatMessage(req, res) {
groupVersion,
resourceName,
} = JSON.parse(req.body.toString());

const clusterUrl = req.headers['x-cluster-url'];
const certificateAuthorityData =
req.headers['x-cluster-certificate-authority-data'];
Expand Down
1 change: 1 addition & 0 deletions public/i18n/en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -776,6 +776,7 @@ kyma-companion:
subtitle: A temporary interruption occured. Please try again.
chat-error: An error occurred
suggestions-error: No suggestions available
http-error: Response status code is {{statusCode}}. Retrying {{attempt}}.
introduction: Hi, I am your Kyma assistant! You can ask me any question, and I will try to help you to the best of my abilities. Meanwhile, you can check the suggested questions below; you may find them helpful!
introduction-no-suggestions: Hi, I am your Kyma assistant! You can ask me any question, and I will try to help you to the best of my abilities. While I don't have any initial suggestions for this resource, feel free to ask me anything you'd like!
placeholder: Message Joule
Expand Down
8 changes: 8 additions & 0 deletions src/components/KymaCompanion/api/error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export class HttpError extends Error {
statusCode: number;

constructor(error: string, statusCode: number) {
super(error);
this.statusCode = statusCode;
}
}
183 changes: 137 additions & 46 deletions src/components/KymaCompanion/api/getChatResponse.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
import { getClusterConfig } from 'state/utils/getBackendInfo';
import { MessageChunk } from '../components/Chat/Message/Message';
import {
ErrorType,
ErrResponse,
MessageChunk,
} from '../components/Chat/Message/Message';
import { HttpError } from './error';
import {
handleChatErrorResponseFn,
handleChatResponseFn,
retryFetch,
} from 'components/KymaCompanion/api/retry';

const MAX_ATTEMPTS = 3;
const RETRY_DELAY = 1_000;

interface ClusterAuth {
token?: string;
Expand All @@ -14,44 +27,17 @@ type GetChatResponseArgs = {
groupVersion?: string;
resourceName?: string;
sessionID: string;
handleChatResponse: (chunk: MessageChunk) => void;
handleError: (error?: string) => void;
handleChatResponse: handleChatResponseFn;
handleError: handleChatErrorResponseFn;
clusterUrl: string;
clusterAuth: ClusterAuth;
certificateAuthorityData: string;
};

export default async function getChatResponse({
query,
namespace = '',
resourceType,
groupVersion = '',
resourceName = '',
sessionID,
handleChatResponse,
handleError,
clusterUrl,
clusterAuth,
certificateAuthorityData,
}: GetChatResponseArgs): Promise<void> {
const { backendAddress } = getClusterConfig();
const url = `${backendAddress}/ai-chat/messages`;
const payload = {
query,
namespace,
resourceType,
groupVersion,
resourceName,
};

const headers: Record<string, string> = {
Accept: 'text/event-stream',
'Content-Type': 'application/json',
'X-Cluster-Certificate-Authority-Data': certificateAuthorityData,
'X-Cluster-Url': clusterUrl,
'Session-Id': sessionID,
};

const fillAuthHeaders = (
headers: Record<string, string>,
clusterAuth: ClusterAuth,
) => {
if (clusterAuth?.token) {
headers['X-K8s-Authorization'] = clusterAuth?.token;
} else if (clusterAuth?.clientCertificateData && clusterAuth?.clientKeyData) {
Expand All @@ -60,37 +46,80 @@ export default async function getChatResponse({
} else {
throw new Error('Missing authentication credentials');
}
};
const createBasicHeaders = (
certificateAuthorityData: string,
clusterUrl: string,
sessionID: string,
) => {
const headers: Record<string, string> = {
Accept: 'text/event-stream',
'Content-Type': 'application/json',
'X-Cluster-Certificate-Authority-Data': certificateAuthorityData,
'X-Cluster-Url': clusterUrl,
'Session-Id': sessionID,
};
return headers;
};

fetch(url, {
async function fetchResponse(
url: RequestInfo | URL,
headers: Record<string, string>,
body: string,
sessionID: string,
handleChatResponse: { (chunk: MessageChunk): void },
handleError: { (errResponse: ErrResponse): void },
): Promise<boolean> {
console.debug('1: Fetch called');
return fetch(url, {
headers,
body: JSON.stringify(payload),
body,
method: 'POST',
})
.then(response => {
.then(async response => {
if (!response.ok) {
throw new Error('Network response was not ok');
throw new HttpError('Network response was not ok', response.status);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error('Failed to get reader from response body');
}
const decoder = new TextDecoder();
readChunk(reader, decoder, handleChatResponse, handleError, sessionID);
await readChunk(
reader,
decoder,
handleChatResponse,
handleError,
sessionID,
);
return true;
})
.catch(error => {
handleError();
if (error instanceof HttpError) {
handleError({
message: error.message,
statusCode: error.statusCode,
type: ErrorType.RETRYABLE,
});
} else {
handleError({
message: error.message,
type: ErrorType.FATAL,
});
}
console.error('Error fetching data:', error);
return false;
});
}

function readChunk(
async function readChunk(
reader: ReadableStreamDefaultReader<Uint8Array>,
decoder: TextDecoder,
handleChatResponse: (chunk: any) => void,
handleError: (error?: string) => void,
handleChatResponse: (chunk: MessageChunk) => void,
handleError: (errResponse: ErrResponse) => void,
sessionID: string,
) {
reader
): Promise<void> {
return reader
.read()
.then(({ done, value }) => {
if (done) {
Expand All @@ -106,7 +135,69 @@ function readChunk(
readChunk(reader, decoder, handleChatResponse, handleError, sessionID);
})
.catch(error => {
handleError();
console.error('Error reading stream:', error);
handleError({
message: error.message,
type: ErrorType.FATAL,
});
});
}

export default async function getChatResponse({
query,
namespace = '',
resourceType,
groupVersion = '',
resourceName = '',
sessionID,
handleChatResponse,
handleError,
clusterUrl,
clusterAuth,
certificateAuthorityData,
}: GetChatResponseArgs): Promise<void> {
const { backendAddress } = getClusterConfig();
const url = `${backendAddress}/ai-chat/messages`;
const payload = {
query,
namespace,
resourceType,
groupVersion,
resourceName,
};
const headers = createBasicHeaders(
certificateAuthorityData,
clusterUrl,
sessionID,
);
fillAuthHeaders(headers, clusterAuth);

const fetchWrapper = async function(
handleResponse: handleChatResponseFn,
handleError: handleChatErrorResponseFn,
): Promise<boolean> {
return fetchResponse(
url,
headers,
JSON.stringify(payload),
sessionID,
handleResponse,
handleError,
);
};

const result = await retryFetch(
fetchWrapper,
handleChatResponse,
handleError,
MAX_ATTEMPTS,
RETRY_DELAY,
);
if (!result) {
handleError({
message:
"Couldn't fetch response from Kyma Companion because of network errors.",
type: ErrorType.FATAL,
});
}
}
113 changes: 113 additions & 0 deletions src/components/KymaCompanion/api/retry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { describe, expect, it, vitest } from 'vitest';

import {
fetchFn,
handleChatErrorResponseFn,
handleChatResponseFn,
retryFetch,
} from 'components/KymaCompanion/api/retry';
import {
ErrorType,
ErrResponse,
MessageChunk,
} from 'components/KymaCompanion/components/Chat/Message/Message';

const successCall = 'Success';
const errorCall = 'Attempt Failed';

const MAX_ATTEMPTS = 3;
const RETRY_DELAY = 1;

describe('Retry mechanism', () => {
it('Success at first attempt', async () => {
//GIVEN
const { fetchFn, handleSuccess, handleError } = createFunctions(0);

//WHEN
const result = await retryFetch(
fetchFn,
handleSuccess,
handleError,
MAX_ATTEMPTS,
RETRY_DELAY,
);

//THEN
expect(handleSuccess).toHaveBeenCalled();
expect(handleError).toHaveBeenCalledTimes(0);
expect(result).toBeTruthy();
});

it('Success at third attempt', async () => {
//GIVEN
const { fetchFn, handleSuccess, handleError } = createFunctions(2);

//WHEN
const result = await retryFetch(
fetchFn,
handleSuccess,
handleError,
MAX_ATTEMPTS,
RETRY_DELAY,
);

//THEN
expect(handleSuccess).toHaveBeenCalled();
expect(handleError).toHaveBeenCalledTimes(2);
expect(result).toBeTruthy();
});

it('All attempts failed', async () => {
//GIVEN
const { fetchFn, handleSuccess, handleError } = createFunctions(3);

//WHEN
const result = await retryFetch(
fetchFn,
handleSuccess,
handleError,
MAX_ATTEMPTS,
RETRY_DELAY,
);

//THEN
expect(handleError).toHaveBeenCalledTimes(3);
expect(handleSuccess).toHaveBeenCalledTimes(0);
expect(result).toBeFalsy();
});
});

function createFunctions(failedAttempt: number) {
const handleSuccess = vitest.fn((chunk: MessageChunk) => {
expect(chunk.data.answer.content).toEqual(successCall);
});
const handleError = vitest.fn((errResponse: ErrResponse) => {
expect(errResponse.message).toEqual(errorCall);
});
const fetchFn = vitest.fn(createFetchFunction(failedAttempt));

return { fetchFn, handleSuccess, handleError };
}

function createFetchFunction(failedAttempts: number): fetchFn {
const fetchFnFactory = (): fetchFn => {
let called = 0;
return async function(
handleSuccess: handleChatResponseFn,
handleError: handleChatErrorResponseFn,
) {
console.debug('1: Fetch called');
called += 1;
if (failedAttempts < called) {
handleSuccess({
data: { answer: { content: successCall, next: 'next' } },
});
return true;
} else {
handleError({ message: errorCall, type: ErrorType.RETRYABLE });
return false;
}
};
};
return fetchFnFactory();
}
Loading
Loading