🧵 feat: Implement Request Executor Pattern for Actions (#4566)

* chore: actions typing

* fix(actions): implement request executor pattern to prevent concurrent execution issues

BREAKING CHANGE: ActionRequest now uses a RequestExecutor pattern for isolated request state

- Introduce RequestConfig class to store immutable configuration
- Add RequestExecutor class to handle isolated request state for each execution
- Modify ActionRequest to act as a facade creating new executors for each operation
- Maintain backward compatibility through delegation and getters
- Add TypeScript types for better type safety
- Fix race conditions in concurrent executions with auth and params

This change prevents state mutation issues when the same action is called
multiple times concurrently, particularly when using authentication. Each
request now gets its own isolated state through a new executor instance,
solving race conditions while maintaining the existing API interface.

* ci: test isolation/immutatability

* chore: Update version to 0.7.51 in data-provider package

* refactor(actions): refactor createActionTool to use request executor pattern
This commit is contained in:
Danny Avila 2024-10-28 13:42:38 -04:00 committed by GitHub
parent 262176fec4
commit 1526b429c9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 414 additions and 74 deletions

View file

@ -65,7 +65,7 @@ describe('ActionRequest', () => {
false,
'application/json',
);
await actionRequest.setParams({ param1: 'value1' });
actionRequest.setParams({ param1: 'value1' });
const response = await actionRequest.execute();
expect(mockedAxios.get).toHaveBeenCalledWith('https://example.com/test', expect.anything());
expect(response.data).toEqual({ success: true, method: 'GET' });
@ -90,7 +90,7 @@ describe('ActionRequest', () => {
false,
'application/json',
);
await actionRequest.setParams({ param: 'test' });
actionRequest.setParams({ param: 'test' });
const response = await actionRequest.execute();
expect(mockedAxios.get).toHaveBeenCalled();
expect(response.data.success).toBe(true);
@ -106,7 +106,7 @@ describe('ActionRequest', () => {
false,
'application/json',
);
await actionRequest.setParams({ param: 'test' });
actionRequest.setParams({ param: 'test' });
const response = await actionRequest.execute();
expect(mockedAxios.post).toHaveBeenCalled();
expect(response.data.success).toBe(true);
@ -122,7 +122,7 @@ describe('ActionRequest', () => {
false,
'application/json',
);
await actionRequest.setParams({ param: 'test' });
actionRequest.setParams({ param: 'test' });
const response = await actionRequest.execute();
expect(mockedAxios.put).toHaveBeenCalled();
expect(response.data.success).toBe(true);
@ -138,7 +138,7 @@ describe('ActionRequest', () => {
false,
'application/json',
);
await actionRequest.setParams({ param: 'test' });
actionRequest.setParams({ param: 'test' });
const response = await actionRequest.execute();
expect(mockedAxios.delete).toHaveBeenCalled();
expect(response.data.success).toBe(true);
@ -154,7 +154,7 @@ describe('ActionRequest', () => {
false,
'application/json',
);
await actionRequest.setParams({ param: 'test' });
actionRequest.setParams({ param: 'test' });
const response = await actionRequest.execute();
expect(mockedAxios.patch).toHaveBeenCalled();
expect(response.data.success).toBe(true);
@ -169,7 +169,7 @@ describe('ActionRequest', () => {
false,
'application/json',
);
await expect(actionRequest.execute()).rejects.toThrow('Unsupported HTTP method: INVALID');
await expect(actionRequest.execute()).rejects.toThrow('Unsupported HTTP method: invalid');
});
it('replaces path parameters with values from toolInput', async () => {
@ -182,20 +182,21 @@ describe('ActionRequest', () => {
'application/json',
);
await actionRequest.setParams({
const executor = actionRequest.createExecutor();
executor.setParams({
stocksTicker: 'AAPL',
multiplier: 5,
startDate: '2023-01-01',
endDate: '2023-12-31',
});
expect(actionRequest.path).toBe('/stocks/AAPL/bars/5');
expect(actionRequest.params).toEqual({
expect(executor.path).toBe('/stocks/AAPL/bars/5');
expect(executor.params).toEqual({
startDate: '2023-01-01',
endDate: '2023-12-31',
});
await actionRequest.execute();
await executor.execute();
expect(mockedAxios.get).toHaveBeenCalledWith('https://example.com/stocks/AAPL/bars/5', {
headers: expect.anything(),
params: {
@ -215,7 +216,271 @@ describe('ActionRequest', () => {
false,
'application/json',
);
await expect(actionRequest.execute()).rejects.toThrow('Unsupported HTTP method: INVALID');
await expect(actionRequest.execute()).rejects.toThrow('Unsupported HTTP method: invalid');
});
describe('ActionRequest Concurrent Execution', () => {
beforeEach(() => {
jest.clearAllMocks();
mockedAxios.get.mockImplementation(async (url, config) => ({
data: { url, params: config?.params, headers: config?.headers },
}));
});
it('maintains isolated state between concurrent executions with different parameters', async () => {
const actionRequest = new ActionRequest(
'https://example.com',
'/math/sqrt/{number}',
'GET',
'getSqrt',
false,
'application/json',
);
// Simulate concurrent requests with different numbers
const numbers = [20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30];
const requests = numbers.map((num) => ({
number: num.toString(),
precision: '2',
}));
const responses = await Promise.all(
requests.map((params) => {
const executor = actionRequest.createExecutor();
return executor.setParams(params).execute();
}),
);
// Verify each response used the correct path parameter
responses.forEach((response, index) => {
const expectedUrl = `https://example.com/math/sqrt/${numbers[index]}`;
expect(response.data.url).toBe(expectedUrl);
expect(response.data.params).toEqual({ precision: '2' });
});
// Verify the correct number of calls were made
expect(mockedAxios.get).toHaveBeenCalledTimes(numbers.length);
});
it('maintains isolated authentication state between concurrent executions', async () => {
const actionRequest = new ActionRequest(
'https://example.com',
'/secure/resource/{id}',
'GET',
'getResource',
false,
'application/json',
);
const requests = [
{
params: { id: '1' },
auth: {
auth: {
type: AuthTypeEnum.ServiceHttp,
authorization_type: AuthorizationTypeEnum.Bearer,
},
api_key: 'token1',
},
},
{
params: { id: '2' },
auth: {
auth: {
type: AuthTypeEnum.ServiceHttp,
authorization_type: AuthorizationTypeEnum.Bearer,
},
api_key: 'token2',
},
},
];
const responses = await Promise.all(
requests.map(async ({ params, auth }) => {
const executor = actionRequest.createExecutor();
return (await executor.setParams(params).setAuth(auth)).execute();
}),
);
// Verify each response had its own auth token
responses.forEach((response, index) => {
const expectedUrl = `https://example.com/secure/resource/${index + 1}`;
expect(response.data.url).toBe(expectedUrl);
expect(response.data.headers).toMatchObject({
Authorization: `Bearer token${index + 1}`,
});
});
});
it('handles mixed authentication types concurrently', async () => {
const actionRequest = new ActionRequest(
'https://example.com',
'/api/{version}/data',
'GET',
'getData',
false,
'application/json',
);
const requests = [
{
params: { version: 'v1' },
auth: {
auth: {
type: AuthTypeEnum.ServiceHttp,
authorization_type: AuthorizationTypeEnum.Bearer,
},
api_key: 'bearer_token',
},
},
{
params: { version: 'v2' },
auth: {
auth: {
type: AuthTypeEnum.ServiceHttp,
authorization_type: AuthorizationTypeEnum.Basic,
},
api_key: 'basic:auth',
},
},
{
params: { version: 'v3' },
auth: {
auth: {
type: AuthTypeEnum.ServiceHttp,
authorization_type: AuthorizationTypeEnum.Custom,
custom_auth_header: 'X-API-Key',
},
api_key: 'custom_key',
},
},
];
const responses = await Promise.all(
requests.map(async ({ params, auth }) => {
const executor = actionRequest.createExecutor();
return (await executor.setParams(params).setAuth(auth)).execute();
}),
);
// Verify each response had the correct auth type and headers
expect(responses[0].data.headers).toMatchObject({
Authorization: 'Bearer bearer_token',
});
expect(responses[1].data.headers).toMatchObject({
Authorization: `Basic ${Buffer.from('basic:auth').toString('base64')}`,
});
expect(responses[2].data.headers).toMatchObject({
'X-API-Key': 'custom_key',
});
});
it('maintains parameter integrity during concurrent path parameter replacement', async () => {
const actionRequest = new ActionRequest(
'https://example.com',
'/users/{userId}/posts/{postId}',
'GET',
'getUserPost',
false,
'application/json',
);
const requests = [
{ userId: '1', postId: 'a', filter: 'recent' },
{ userId: '2', postId: 'b', filter: 'popular' },
{ userId: '3', postId: 'c', filter: 'trending' },
];
const responses = await Promise.all(
requests.map((params) => {
const executor = actionRequest.createExecutor();
return executor.setParams(params).execute();
}),
);
responses.forEach((response, index) => {
const expectedUrl = `https://example.com/users/${requests[index].userId}/posts/${requests[index].postId}`;
expect(response.data.url).toBe(expectedUrl);
expect(response.data.params).toEqual({ filter: requests[index].filter });
});
});
it('preserves original ActionRequest state after multiple executions', async () => {
const actionRequest = new ActionRequest(
'https://example.com',
'/original/{param}',
'GET',
'testOp',
false,
'application/json',
);
// Store original values
const originalPath = actionRequest.path;
const originalDomain = actionRequest.domain;
const originalMethod = actionRequest.method;
// Perform multiple concurrent executions
await Promise.all([
actionRequest.createExecutor().setParams({ param: '1' }).execute(),
actionRequest.createExecutor().setParams({ param: '2' }).execute(),
actionRequest.createExecutor().setParams({ param: '3' }).execute(),
]);
// Verify original ActionRequest remains unchanged
expect(actionRequest.path).toBe(originalPath);
expect(actionRequest.domain).toBe(originalDomain);
expect(actionRequest.method).toBe(originalMethod);
});
it('shares immutable configuration between executors from the same ActionRequest', () => {
const actionRequest = new ActionRequest(
'https://example.com',
'/api/{version}/data',
'GET',
'getData',
false,
'application/json',
);
// Create multiple executors
const executor1 = actionRequest.createExecutor();
const executor2 = actionRequest.createExecutor();
const executor3 = actionRequest.createExecutor();
// Test that the configuration properties are shared
[executor1, executor2, executor3].forEach((executor) => {
expect(executor.getConfig()).toBeDefined();
expect(executor.getConfig()).toEqual({
domain: 'https://example.com',
basePath: '/api/{version}/data',
method: 'GET',
operation: 'getData',
isConsequential: false,
contentType: 'application/json',
});
});
// Verify that config objects are the exact same instance (shared reference)
expect(executor1.getConfig()).toBe(executor2.getConfig());
expect(executor2.getConfig()).toBe(executor3.getConfig());
// Verify that modifying mutable state doesn't affect other executors
executor1.setParams({ version: 'v1' });
executor2.setParams({ version: 'v2' });
executor3.setParams({ version: 'v3' });
expect(executor1.path).toBe('/api/v1/data');
expect(executor2.path).toBe('/api/v2/data');
expect(executor3.path).toBe('/api/v3/data');
// Verify that the original config remains unchanged
expect(executor1.getConfig().basePath).toBe('/api/{version}/data');
expect(executor2.getConfig().basePath).toBe('/api/{version}/data');
expect(executor3.getConfig().basePath).toBe('/api/{version}/data');
});
});
});
@ -233,7 +498,8 @@ describe('Authentication Handling', () => {
const api_key = 'user:pass';
const encodedCredentials = Buffer.from('user:pass').toString('base64');
actionRequest.setAuth({
const executor = actionRequest.createExecutor();
await executor.setParams({ param1: 'value1' }).setAuth({
auth: {
type: AuthTypeEnum.ServiceHttp,
authorization_type: AuthorizationTypeEnum.Basic,
@ -241,13 +507,13 @@ describe('Authentication Handling', () => {
api_key,
});
await actionRequest.setParams({ param1: 'value1' });
await actionRequest.execute();
await executor.execute();
expect(mockedAxios.get).toHaveBeenCalledWith('https://example.com/test', {
headers: expect.objectContaining({
Authorization: `Basic ${encodedCredentials}`,
'Content-Type': 'application/json',
}),
params: expect.anything(),
params: { param1: 'value1' },
});
});
@ -260,20 +526,23 @@ describe('Authentication Handling', () => {
false,
'application/json',
);
actionRequest.setAuth({
const executor = actionRequest.createExecutor();
await executor.setParams({ param1: 'value1' }).setAuth({
auth: {
type: AuthTypeEnum.ServiceHttp,
authorization_type: AuthorizationTypeEnum.Bearer,
},
api_key: 'token123',
});
await actionRequest.setParams({ param1: 'value1' });
await actionRequest.execute();
await executor.execute();
expect(mockedAxios.get).toHaveBeenCalledWith('https://example.com/test', {
headers: expect.objectContaining({
Authorization: 'Bearer token123',
'Content-Type': 'application/json',
}),
params: expect.anything(),
params: { param1: 'value1' },
});
});
@ -286,22 +555,24 @@ describe('Authentication Handling', () => {
false,
'application/json',
);
// Updated to match ActionMetadata structure
actionRequest.setAuth({
const executor = actionRequest.createExecutor();
await executor.setParams({ param1: 'value1' }).setAuth({
auth: {
type: AuthTypeEnum.ServiceHttp, // Assuming this is a valid enum or value for your context
authorization_type: AuthorizationTypeEnum.Custom, // Assuming Custom means using a custom header
type: AuthTypeEnum.ServiceHttp,
authorization_type: AuthorizationTypeEnum.Custom,
custom_auth_header: 'X-API-KEY',
},
api_key: 'abc123',
});
await actionRequest.setParams({ param1: 'value1' });
await actionRequest.execute();
await executor.execute();
expect(mockedAxios.get).toHaveBeenCalledWith('https://example.com/test', {
headers: expect.objectContaining({
'X-API-KEY': 'abc123',
'Content-Type': 'application/json',
}),
params: expect.anything(),
params: { param1: 'value1' },
});
});
});
@ -312,7 +583,7 @@ describe('resolveRef', () => {
const flowchartRequestRef = (
openapiSpec.paths['/ai.chatgpt.render-flowchart']?.post
?.requestBody as OpenAPIV3.RequestBodyObject
)?.content['application/json'].schema;
).content['application/json'].schema;
expect(flowchartRequestRef).toBeDefined();
const resolvedFlowchartRequest = resolveRef(
flowchartRequestRef as OpenAPIV3.RequestBodyObject,