Skip to content

Commit

Permalink
feat: http error handling without retries (#226)
Browse files Browse the repository at this point in the history
  • Loading branch information
vtsaplin committed Mar 15, 2024
1 parent 08c15ad commit 495011f
Show file tree
Hide file tree
Showing 16 changed files with 189 additions and 132 deletions.
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

0 comments on commit 495011f

Please sign in to comment.