Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: http error handling without retries #226

Merged
merged 8 commits into from Mar 15, 2024
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
87 changes: 43 additions & 44 deletions actions/AuthAction.js
Expand Up @@ -12,58 +12,57 @@
const { Core } = require('@adobe/aio-sdk');
const QueryStringAddon = require('wretch/addons/queryString');
const { ImsClient } = require('./ImsClient.js');
const { wretchRetry } = require('./Network.js');
const wretch = require('./Network.js');

const logger = Core.Logger('AuthAction');

async function isValidToken(endpoint, clientId, token) {
return wretchRetry(`${endpoint}/ims/validate_token/v1`)
.addon(QueryStringAddon).query({
client_id: clientId,
type: 'access_token',
})
.headers({
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
})
.get()
.json()
.then((json) => {
return json.valid;
})
.catch((error) => {
logger.error(error);
return false;
});
try {
const response = await wretch(`${endpoint}/ims/validate_token/v1`)
.addon(QueryStringAddon).query({
client_id: clientId,
type: 'access_token',
})
.headers({
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
})
.get()
.json();
return response.valid;
} catch (error) {
logger.error(error);
return false;
}
}

async function checkForProductContext(endpoint, clientId, org, token, productContext) {
return wretchRetry(`${endpoint}/ims/profile/v1`)
.addon(QueryStringAddon).query({
client_id: clientId,
})
.headers({
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
})
.get()
.json()
.then(async (json) => {
if (Array.isArray(json.projectedProductContext)) {
const filteredProductContext = json.projectedProductContext
.filter((obj) => obj.prodCtx.serviceCode === productContext);
try {
const response = await wretch(`${endpoint}/ims/profile/v1`)
.addon(QueryStringAddon).query({
client_id: clientId,
})
.headers({
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
})
.get()
.json();

// For each entry in filteredProductContext check that
// there is at least one entry where imsOrg matches the owningEntity property
// otherwise, if no match, the user is not authorized
return filteredProductContext.some((obj) => obj.prodCtx.owningEntity === org);
}
return false;
})
.catch((error) => {
logger.error(error);
return false;
});
if (Array.isArray(response.projectedProductContext)) {
const filteredProductContext = response.projectedProductContext
.filter((obj) => obj.prodCtx.serviceCode === productContext);

// For each entry in filteredProductContext check that
// there is at least one entry where imsOrg matches the owningEntity property
// otherwise, if no match, the user is not authorized
return filteredProductContext.some((obj) => obj.prodCtx.owningEntity === org);
}
return false;
} catch (error) {
logger.error(error);
return false;
}
}

function asAuthAction(action) {
Expand Down
20 changes: 11 additions & 9 deletions actions/FirefallClient.js
Expand Up @@ -10,20 +10,22 @@
* governing permissions and limitations under the License.
*/
const { Core } = require('@adobe/aio-sdk');
const { wretchRetry } = require('./Network.js');
const wretch = require('./Network.js');
const InternalError = require('./InternalError.js');

const logger = Core.Logger('FirefallAction');

const FIREFALL_ERROR_CODES = {
const ERROR_CODES = {
defaultCompletion: 'An error occurred while generating results',
defaultFeedback: 'An error occurred while sending feedback',
400: "The response was filtered due to the prompt triggering Generative AI's content management policy. Please modify your prompt and retry.",
408: "Generative AI's request timed out. Please try again.",
429: "Generative AI's Rate limit exceeded. Please wait one minute and try again.",
};

function firefallErrorMessage(defaultMessage, errorStatus) {
const errorString = FIREFALL_ERROR_CODES[errorStatus] ?? defaultMessage;
return `IS-ERROR: ${errorString} (${errorStatus}).`;
function toFirefallError(error, defaultMessage) {
const errorMessage = ERROR_CODES[error.status] ?? defaultMessage;
return new InternalError(400, `IS-ERROR: ${errorMessage} (${error.status}).`);
}

class FirefallClient {
Expand All @@ -38,7 +40,7 @@ class FirefallClient {
const startTime = Date.now();

try {
const response = await wretchRetry(`${this.endpoint}/v1/completions`)
const response = await wretch(`${this.endpoint}/v1/completions`)
.headers({
'x-gw-ims-org-id': this.org,
'x-api-key': this.apiKey,
Expand Down Expand Up @@ -69,15 +71,15 @@ class FirefallClient {
return response;
} catch (error) {
logger.error('Failed generating results:', error);
throw new Error(firefallErrorMessage(FIREFALL_ERROR_CODES.defaultCompletion, error.status));
throw toFirefallError(error, ERROR_CODES.defaultCompletion);
}
}

async feedback(queryId, sentiment) {
const startTime = Date.now();

try {
const response = await wretchRetry(`${this.endpoint}/v1/feedback`)
const response = await wretch(`${this.endpoint}/v1/feedback`)
.headers({
Authorization: `Bearer ${this.accessToken}`,
'x-api-key': this.apiKey,
Expand All @@ -99,7 +101,7 @@ class FirefallClient {
return response;
} catch (error) {
logger.error('Failed sending feedback:', error);
throw new Error(firefallErrorMessage(FIREFALL_ERROR_CODES.defaultFeedback, error.status));
throw toFirefallError(error, ERROR_CODES.defaultFeedback);
}
}
}
Expand Down
51 changes: 18 additions & 33 deletions actions/FirefallClient.test.js
Expand Up @@ -9,14 +9,9 @@
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
import { WretchError } from 'wretch';
import { FirefallClient } from './FirefallClient.js';
import { wretchRetry } from './Network.js';
import wretch from './Network.js';

// Mock the 'Network.js' module
jest.mock('./Network.js');

// Mock the '@adobe/aio-sdk' module
jest.mock('@adobe/aio-sdk', () => ({
Core: {
Logger: jest.fn().mockReturnValue({
Expand All @@ -26,44 +21,34 @@ jest.mock('@adobe/aio-sdk', () => ({
},
}));

let firefall;
let error;

function createFirefallClient() {
const client = new FirefallClient('endpoint', 'apiKey', 'org', 'accessToken');
return client;
}

function createWretchError(status) {
const wretchError = new WretchError('Internal Server Error');
wretchError.status = status;
return wretchError;
}

beforeEach(() => {
jest.clearAllMocks();
firefall = createFirefallClient();
error = createWretchError(500);
wretchRetry.mockImplementation(() => ({
jest.mock('./Network.js', () => {
const wretchMock = {
headers: jest.fn().mockReturnThis(),
post: jest.fn().mockReturnThis(),
json: jest.fn().mockRejectedValue(error),
}));
json: jest.fn().mockResolvedValue({}),
};
return jest.fn().mockImplementation(() => wretchMock);
});

describe('FirefallClient', () => {
const sut = new FirefallClient('endpoint', 'apiKey', 'org', 'accessToken');

beforeEach(() => {
jest.clearAllMocks();
});

test('handles 400 http status in completion method', async () => {
error.status = 400;
await expect(firefall.completion('prompt')).rejects.toThrow("IS-ERROR: The response was filtered due to the prompt triggering Generative AI's content management policy. Please modify your prompt and retry. (400).");
wretch().json.mockRejectedValue({ status: 400 });
await expect(sut.completion('prompt')).rejects.toThrow("IS-ERROR: The response was filtered due to the prompt triggering Generative AI's content management policy. Please modify your prompt and retry. (400).");
});

test('handles 429 http status in completion method', async () => {
error.status = 429;
await expect(firefall.completion('prompt')).rejects.toThrow("IS-ERROR: Generative AI's Rate limit exceeded. Please wait one minute and try again. (429).");
wretch().json.mockRejectedValue({ status: 429 });
await expect(sut.completion('prompt')).rejects.toThrow("IS-ERROR: Generative AI's Rate limit exceeded. Please wait one minute and try again. (429).");
});

test('handless any http status in the feedback method', async () => {
error.status = 500;
await expect(firefall.feedback('queryId', 'sentiment')).rejects.toThrow('An error occurred while sending feedback');
wretch().json.mockRejectedValue({ status: 500 });
await expect(sut.feedback('queryId', 'sentiment')).rejects.toThrow('An error occurred while sending feedback');
});
});
40 changes: 27 additions & 13 deletions actions/GenericAction.js
Expand Up @@ -9,22 +9,36 @@
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

const InternalError = require('./InternalError.js');

function createResponse(status, body) {
return {
headers: { 'Content-Type': 'application/json' },
statusCode: status,
body,
};
}

function createSuccessResponse(body) {
return createResponse(200, body);
}

function createErrorResponse(status, message) {
return createResponse(status, { error: message });
}

function asGenericAction(action) {
return async function (params) {
return async (params) => {
try {
return {
headers: { 'Content-Type': 'application/json' },
statusCode: 200,
body: await action(params),
};
return createSuccessResponse(await action(params));
} catch (e) {
return {
headers: { 'Content-Type': 'application/json' },
statusCode: e.status ?? 500,
body: {
error: e.message ?? 'Internal Server Error',
},
};
if (e instanceof InternalError) {
console.error(`Internal error: ${e.message} (${e.status})`);
return createErrorResponse(e.status, e.message);
}
console.error(`Unexpected error: ${e.message}`);
return createErrorResponse(500, e.message ?? 'Internal Server Error');
}
};
}
Expand Down
5 changes: 2 additions & 3 deletions actions/ImsClient.js
Expand Up @@ -10,8 +10,7 @@
* governing permissions and limitations under the License.
*/
const FormUrlAddon = require('wretch/addons/formUrl');
const { wretchRetry } = require('./Network.js');
// const FormDataAddon = require('wretch/addons/formData');
const wretch = require('./Network.js');

class ImsClient {
constructor(endpoint, clientId, clientSecret, permAuthCode) {
Expand All @@ -22,7 +21,7 @@ class ImsClient {
}

async getServiceToken() {
const json = await wretchRetry(`${this.endpoint}/ims/token/v1`)
const json = await wretch(`${this.endpoint}/ims/token/v1`)
.addon(FormUrlAddon).formUrl({
client_id: this.clientId,
client_secret: this.clientSecret,
Expand Down
20 changes: 20 additions & 0 deletions actions/InternalError.js
@@ -0,0 +1,20 @@
/*
* Copyright 2023 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
class InternalError extends Error {
constructor(status, message) {
super(message);
this.name = 'InternalError';
this.status = status;
}
}

module.exports = InternalError;
36 changes: 29 additions & 7 deletions actions/Network.js
Expand Up @@ -9,15 +9,37 @@
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
const AbortAddon = require('wretch/addons/abort');

const { WretchError } = require('wretch');
const wretch = require('wretch');
const { retry } = require('wretch/middlewares/retry');
const { Core } = require('@adobe/aio-sdk');

const logger = Core.Logger('FirefallAction');

const REQUEST_TIMEOUT = 55 * 1000;

function createWretchError(status, message) {
const error = new WretchError();
error.status = status;
error.message = message;
return error;
}

function wretchRetry(url) {
function wretchWithOptions(url) {
return wretch(url)
.middlewares([retry({
retryOnNetworkError: true,
until: (response) => response && (response.ok || (response.status >= 400 && response.status < 500)),
})]);
.addon(AbortAddon())
.resolve((resolver) => resolver.setTimeout(REQUEST_TIMEOUT))
.resolve((resolver) => {
return resolver.fetchError((error) => {
if (error.name === 'AbortError') {
logger.error('Request aborted', error);
throw createWretchError(408, 'Request timed out');
}
logger.error('Network error', error);
throw createWretchError(500, 'Network error');
});
});
}

module.exports = { wretchRetry };
module.exports = wretchWithOptions;
2 changes: 1 addition & 1 deletion actions/csv/index.js
Expand Up @@ -9,8 +9,8 @@
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
const wretch = require('wretch');
const Papa = require('papaparse');
const wretch = require('../Network.js');
const { asGenericAction } = require('../GenericAction.js');

async function main({ url }) {
Expand Down