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 4 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
88 changes: 44 additions & 44 deletions actions/AuthAction.js
Expand Up @@ -12,58 +12,58 @@
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();
const { valid } = response;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

return response.valid?

return 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
19 changes: 10 additions & 9 deletions actions/FirefallClient.js
Expand Up @@ -10,20 +10,21 @@
* governing permissions and limitations under the License.
*/
const { Core } = require('@adobe/aio-sdk');
const { wretchRetry } = require('./Network.js');
const { wretch, NetworkError } = require('./Network.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 NetworkError(400, `IS-ERROR: ${errorMessage} (${error.status}).`);
}

class FirefallClient {
Expand All @@ -38,7 +39,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 +70,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 +100,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
16 changes: 6 additions & 10 deletions actions/FirefallClient.test.js
Expand Up @@ -9,9 +9,8 @@
* 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 { NetworkError, wretch } from './Network.js';

// Mock the 'Network.js' module
jest.mock('./Network.js');
Expand All @@ -30,21 +29,18 @@ let firefall;
let error;

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

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

beforeEach(() => {
jest.clearAllMocks();
firefall = createFirefallClient();
error = createWretchError(500);
wretchRetry.mockImplementation(() => ({
error = createNetworkError(500);
wretch.mockImplementation(() => ({
headers: jest.fn().mockReturnThis(),
post: jest.fn().mockReturnThis(),
json: jest.fn().mockRejectedValue(error),
Expand Down
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 { NetworkError } = require('./Network.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 NetworkError) {
console.error(`Network error: ${e.message} (${e.status})`);
return createErrorResponse(e.status, e.message);
}
console.error(`Internal 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
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 wretch = require('wretch');
const { retry } = require('wretch/middlewares/retry');
const { Core } = require('@adobe/aio-sdk');

const REQUEST_TIMEOUT = 55 * 1000;

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

class NetworkError extends Error {
constructor(status, message) {
super(message);
this.name = 'NetworkError';
this.status = status;
}
}

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((_) => _.setTimeout(REQUEST_TIMEOUT))
.resolve((_) => {
return _.fetchError((error) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generally, _ is used as a placeholder for unused parameter. However, using _ in this way creates confusion as to why a method is called on it. I would have preferred a parameter name such as request.

if (error.name === 'AbortError') {
logger.error('Request aborted', error);
throw new NetworkError(408, 'Request timed out');
}
logger.error('Network error', error);
throw new NetworkError(500, 'Network error');
});
});
}

module.exports = { wretchRetry };
module.exports = { wretch: wretchWithOptions, NetworkError };
8 changes: 4 additions & 4 deletions actions/target/index.js
Expand Up @@ -9,7 +9,7 @@
* 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 { wretch } = require('../Network.js');
const { asGenericAction } = require('../GenericAction.js');

const MIN_DESCRIPTION_LENGTH = 5;
Expand All @@ -18,7 +18,7 @@ async function main({ __ow_headers: headers, org, TARGET_API_KEY }) {
const { authorization } = headers;
const accessToken = authorization.split(' ')[1];

const json = await wretch(`https://mc.adobe.io/${org}/target/audiences`)
const response = await wretch(`https://mc.adobe.io/${org}/target/audiences`)
.headers({
'x-api-key': TARGET_API_KEY,
})
Expand All @@ -27,11 +27,11 @@ async function main({ __ow_headers: headers, org, TARGET_API_KEY }) {
.get()
.json();

if (!json.audiences) {
if (!response.audiences) {
throw new Error('Failed to fetch audiences');
}

return json.audiences
return response.audiences
.filter((audience) => audience.name && audience.type === 'reusable')
.map((audience) => ({
id: audience.id,
Expand Down
4 changes: 2 additions & 2 deletions app.config.yaml
Expand Up @@ -19,7 +19,7 @@ application:
IMS_SERVICE_PERM_AUTH_CODE: $IMS_SERVICE_PERM_AUTH_CODE
IMS_PRODUCT_CONTEXT: $IMS_PRODUCT_CONTEXT
limits:
timeout: 180000
timeout: 60000
feedback:
function: actions/feedback/index.js
web: true
Expand All @@ -34,7 +34,7 @@ application:
IMS_SERVICE_PERM_AUTH_CODE: $IMS_SERVICE_PERM_AUTH_CODE
IMS_PRODUCT_CONTEXT: $IMS_PRODUCT_CONTEXT
limits:
timeout: 180000
timeout: 60000
target:
function: actions/target/index.js
web: true
Expand Down
2 changes: 1 addition & 1 deletion e2e/WebApp.test.js
Expand Up @@ -45,7 +45,7 @@ jest.mock('@adobe/exc-app/settings', () => ({
}));

jest.mock('../web-src/src/helpers/NetworkHelper.js', () => ({
wretchRetry: jest.fn().mockImplementation(() => ({
wretch: jest.fn().mockImplementation(() => ({
get: jest.fn().mockImplementation(() => ({
json: jest.fn().mockRejectedValue(new Error('error')),
})),
Expand Down
1 change: 1 addition & 0 deletions web-src/src/components/GenerateButton.js
Expand Up @@ -39,6 +39,7 @@ export function GenerateButton() {
const setIsOpenPromptEditor = useSetRecoilState(promptEditorState);
const [generationInProgress, setGenerationInProgress] = useState(false);
const saveResults = useSaveResults();

const generateResults = useCallback(async () => {
try {
const finalPrompt = renderPrompt(prompt, parameters);
Expand Down