feat(core): Add retry execution endpoint to public api (#19132)

Co-authored-by: Csaba Tuncsik <csaba.tuncsik@gmail.com>
Co-authored-by: Marc Littlemore <MarcL@users.noreply.github.com>
This commit is contained in:
Konstantin Tieber
2025-09-11 10:12:53 +02:00
committed by GitHub
parent b147709189
commit c4f41bb534
16 changed files with 229 additions and 20 deletions

View File

@@ -125,6 +125,7 @@ exports[`Scope Information ensure scopes are defined correctly 1`] = `
"dataStore:*", "dataStore:*",
"execution:delete", "execution:delete",
"execution:read", "execution:read",
"execution:retry",
"execution:list", "execution:list",
"execution:get", "execution:get",
"execution:*", "execution:*",

View File

@@ -27,7 +27,7 @@ export const RESOURCES = {
insights: ['list'] as const, insights: ['list'] as const,
oidc: ['manage'] as const, oidc: ['manage'] as const,
dataStore: [...DEFAULT_OPERATIONS, 'readRow', 'writeRow', 'listProject'] as const, dataStore: [...DEFAULT_OPERATIONS, 'readRow', 'writeRow', 'listProject'] as const,
execution: ['delete', 'read', 'list', 'get'] as const, execution: ['delete', 'read', 'retry', 'list', 'get'] as const,
workflowTags: ['update', 'list'] as const, workflowTags: ['update', 'list'] as const,
role: ['manage'] as const, role: ['manage'] as const,
} as const; } as const;
@@ -39,7 +39,7 @@ export const API_KEY_RESOURCES = {
securityAudit: ['generate'] as const, securityAudit: ['generate'] as const,
project: ['create', 'update', 'delete', 'list'] as const, project: ['create', 'update', 'delete', 'list'] as const,
user: ['read', 'list', 'create', 'changeRole', 'delete', 'enforceMfa'] as const, user: ['read', 'list', 'create', 'changeRole', 'delete', 'enforceMfa'] as const,
execution: ['delete', 'read', 'list', 'get'] as const, execution: ['delete', 'read', 'retry', 'list', 'get'] as const,
credential: ['create', 'move', 'delete'] as const, credential: ['create', 'move', 'delete'] as const,
sourceControl: ['pull'] as const, sourceControl: ['pull'] as const,
workflowTags: ['update', 'list'] as const, workflowTags: ['update', 'list'] as const,

View File

@@ -34,6 +34,7 @@ export const OWNER_API_KEY_SCOPES: ApiKeyScope[] = [
'workflow:deactivate', 'workflow:deactivate',
'execution:delete', 'execution:delete',
'execution:read', 'execution:read',
'execution:retry',
'execution:list', 'execution:list',
'credential:create', 'credential:create',
'credential:move', 'credential:move',
@@ -59,6 +60,7 @@ export const MEMBER_API_KEY_SCOPES: ApiKeyScope[] = [
'workflow:deactivate', 'workflow:deactivate',
'execution:delete', 'execution:delete',
'execution:read', 'execution:read',
'execution:retry',
'execution:list', 'execution:list',
'credential:create', 'credential:create',
'credential:move', 'credential:move',
@@ -84,6 +86,7 @@ export const API_KEY_SCOPES_FOR_IMPLICIT_PERSONAL_PROJECT: ApiKeyScope[] = [
'workflow:deactivate', 'workflow:deactivate',
'execution:delete', 'execution:delete',
'execution:read', 'execution:read',
'execution:retry',
'execution:list', 'execution:list',
'credential:create', 'credential:create',
'credential:move', 'credential:move',

View File

@@ -206,6 +206,11 @@ export type RelayEventMap = {
publicApi: boolean; publicApi: boolean;
}; };
'user-retried-execution': {
userId: string;
publicApi: boolean;
};
'user-retrieved-workflow': { 'user-retrieved-workflow': {
userId: string; userId: string;
publicApi: boolean; publicApi: boolean;

View File

@@ -114,7 +114,7 @@ export class ExecutionService {
async findOne( async findOne(
req: ExecutionRequest.GetOne | ExecutionRequest.Update, req: ExecutionRequest.GetOne | ExecutionRequest.Update,
sharedWorkflowIds: string[], sharedWorkflowIds: string[],
): Promise<IExecutionResponse | IExecutionFlattedResponse | undefined> { ): Promise<IExecutionFlattedResponse | undefined> {
if (!sharedWorkflowIds.length) return undefined; if (!sharedWorkflowIds.length) return undefined;
const { id: executionId } = req.params; const { id: executionId } = req.params;
@@ -131,7 +131,10 @@ export class ExecutionService {
return execution; return execution;
} }
async retry(req: ExecutionRequest.Retry, sharedWorkflowIds: string[]) { async retry(
req: ExecutionRequest.Retry,
sharedWorkflowIds: string[],
): Promise<Omit<IExecutionResponse, 'createdAt'>> {
const { id: executionId } = req.params; const { id: executionId } = req.params;
const execution = await this.executionRepository.findWithUnflattenedData( const execution = await this.executionRepository.findWithUnflattenedData(
executionId, executionId,
@@ -243,7 +246,20 @@ export class ExecutionService {
throw new UnexpectedError('The retry did not start for an unknown reason.'); throw new UnexpectedError('The retry did not start for an unknown reason.');
} }
return executionData.status; return {
id: retriedExecutionId,
mode: executionData.mode,
startedAt: executionData.startedAt,
workflowId: execution.workflowId,
finished: executionData.finished ?? false,
retryOf: executionId,
status: executionData.status,
waitTill: executionData.waitTill,
data: executionData.data,
workflowData: execution.workflowData,
customData: execution.customData,
annotation: execution.annotation,
};
} }
async delete(req: ExecutionRequest.Delete, sharedWorkflowIds: string[]) { async delete(req: ExecutionRequest.Delete, sharedWorkflowIds: string[]) {

View File

@@ -14,8 +14,6 @@ export declare namespace ExecutionRequest {
lastId: string; lastId: string;
firstId: string; firstId: string;
}; };
type GetOne = { unflattedResponse: 'true' | 'false' };
} }
namespace BodyParams { namespace BodyParams {
@@ -41,11 +39,11 @@ export declare namespace ExecutionRequest {
rangeQuery: ExecutionSummaries.RangeQuery; // parsed from query params rangeQuery: ExecutionSummaries.RangeQuery; // parsed from query params
}; };
type GetOne = AuthenticatedRequest<RouteParams.ExecutionId, {}, {}, QueryParams.GetOne>; type GetOne = AuthenticatedRequest<RouteParams.ExecutionId>;
type Delete = AuthenticatedRequest<{}, {}, BodyParams.DeleteFilter>; type Delete = AuthenticatedRequest<{}, {}, BodyParams.DeleteFilter>;
type Retry = AuthenticatedRequest<RouteParams.ExecutionId, {}, { loadWorkflow: boolean }, {}>; type Retry = AuthenticatedRequest<RouteParams.ExecutionId, {}, { loadWorkflow?: boolean }, {}>;
type Stop = AuthenticatedRequest<RouteParams.ExecutionId>; type Stop = AuthenticatedRequest<RouteParams.ExecutionId>;

View File

@@ -34,6 +34,7 @@ export declare namespace ExecutionRequest {
type Get = AuthenticatedRequest<{ id: string }, {}, {}, { includeData?: boolean }>; type Get = AuthenticatedRequest<{ id: string }, {}, {}, { includeData?: boolean }>;
type Delete = Get; type Delete = Get;
type Retry = AuthenticatedRequest<{ id: string }, {}, { loadWorkflow?: boolean }, {}>;
} }
export declare namespace TagRequest { export declare namespace TagRequest {

View File

@@ -5,7 +5,11 @@ import { replaceCircularReferences } from 'n8n-workflow';
import { ActiveExecutions } from '@/active-executions'; import { ActiveExecutions } from '@/active-executions';
import { ConcurrencyControlService } from '@/concurrency/concurrency-control.service'; import { ConcurrencyControlService } from '@/concurrency/concurrency-control.service';
import { AbortedExecutionRetryError } from '@/errors/aborted-execution-retry.error';
import { QueuedExecutionRetryError } from '@/errors/queued-execution-retry.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { EventService } from '@/events/event.service'; import { EventService } from '@/events/event.service';
import { ExecutionService } from '@/executions/execution.service';
import type { ExecutionRequest } from '../../../types'; import type { ExecutionRequest } from '../../../types';
import { apiKeyHasScope, validCursor } from '../../shared/middlewares/global.middleware'; import { apiKeyHasScope, validCursor } from '../../shared/middlewares/global.middleware';
@@ -149,4 +153,41 @@ export = {
}); });
}, },
], ],
retryExecution: [
apiKeyHasScope('execution:retry'),
async (req: ExecutionRequest.Retry, res: express.Response): Promise<express.Response> => {
const sharedWorkflowsIds = await getSharedWorkflowIds(req.user, ['workflow:read']);
// user does not have workflows hence no executions
// or the execution they are trying to access belongs to a workflow they do not own
if (!sharedWorkflowsIds.length) {
return res.status(404).json({ message: 'Not Found' });
}
try {
const retriedExecution = await Container.get(ExecutionService).retry(
req,
sharedWorkflowsIds,
);
Container.get(EventService).emit('user-retried-execution', {
userId: req.user.id,
publicApi: true,
});
return res.json(replaceCircularReferences(retriedExecution));
} catch (error) {
if (
error instanceof QueuedExecutionRetryError ||
error instanceof AbortedExecutionRetryError
) {
return res.status(409).json({ message: error.message });
} else if (error instanceof NotFoundError) {
return res.status(404).json({ message: error.message });
} else {
throw error;
}
}
},
],
}; };

View File

@@ -0,0 +1,32 @@
post:
x-eov-operation-id: retryExecution
x-eov-operation-handler: v1/handlers/executions/executions.handler
tags:
- Execution
summary: Retry an execution
description: Retry an execution from your instance.
parameters:
- $ref: '../schemas/parameters/executionId.yml'
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
loadWorkflow:
type: boolean
description: Whether to load the currently saved workflow to execute instead of the one saved at the time of the execution. If set to true, it will retry with the latest version of the workflow.
responses:
'200':
description: Operation successful.
content:
application/json:
schema:
$ref: '../schemas/execution.yml'
'401':
$ref: '../../../../shared/spec/responses/unauthorized.yml'
'404':
$ref: '../../../../shared/spec/responses/notFound.yml'
'409':
$ref: '../../../../shared/spec/responses/conflict.yml'

View File

@@ -22,6 +22,8 @@ put:
- Variables - Variables
summary: Update a variable summary: Update a variable
description: Update a variable from your instance. description: Update a variable from your instance.
parameters:
- $ref: '../schemas/parameters/variableId.yml'
requestBody: requestBody:
description: Payload for variable to update. description: Payload for variable to update.
content: content:

View File

@@ -48,6 +48,8 @@ paths:
$ref: './handlers/executions/spec/paths/executions.yml' $ref: './handlers/executions/spec/paths/executions.yml'
/executions/{id}: /executions/{id}:
$ref: './handlers/executions/spec/paths/executions.id.yml' $ref: './handlers/executions/spec/paths/executions.id.yml'
/executions/{id}/retry:
$ref: './handlers/executions/spec/paths/executions.id.retry.yml'
/tags: /tags:
$ref: './handlers/tags/spec/paths/tags.yml' $ref: './handlers/tags/spec/paths/tags.yml'
/tags/{id}: /tags/{id}:

View File

@@ -14,3 +14,5 @@ UserIdentifier:
$ref: '../../../handlers/users/spec/schemas/parameters/userIdentifier.yml' $ref: '../../../handlers/users/spec/schemas/parameters/userIdentifier.yml'
IncludeRole: IncludeRole:
$ref: '../../../handlers/users/spec/schemas/parameters/includeRole.yml' $ref: '../../../handlers/users/spec/schemas/parameters/includeRole.yml'
VariableId:
$ref: '../../../handlers/variables/spec/schemas/parameters/variableId.yml'

View File

@@ -7,10 +7,8 @@ import {
testDb, testDb,
} from '@n8n/backend-test-utils'; } from '@n8n/backend-test-utils';
import type { ExecutionEntity, User } from '@n8n/db'; import type { ExecutionEntity, User } from '@n8n/db';
import type { ExecutionStatus } from 'n8n-workflow'; import { Container } from '@n8n/di';
import { UnexpectedError, type ExecutionStatus } from 'n8n-workflow';
import type { ActiveWorkflowManager } from '@/active-workflow-manager';
import { Telemetry } from '@/telemetry';
import { import {
createdExecutionWithStatus, createdExecutionWithStatus,
@@ -23,6 +21,12 @@ import { createMemberWithApiKey, createOwnerWithApiKey } from '../shared/db/user
import type { SuperAgentTest } from '../shared/types'; import type { SuperAgentTest } from '../shared/types';
import * as utils from '../shared/utils/'; import * as utils from '../shared/utils/';
import type { ActiveWorkflowManager } from '@/active-workflow-manager';
import { ExecutionService } from '@/executions/execution.service';
import { Telemetry } from '@/telemetry';
import { QueuedExecutionRetryError } from '@/errors/queued-execution-retry.error';
import { AbortedExecutionRetryError } from '@/errors/aborted-execution-retry.error';
let owner: User; let owner: User;
let user1: User; let user1: User;
let user2: User; let user2: User;
@@ -234,6 +238,107 @@ describe('DELETE /executions/:id', () => {
}); });
}); });
describe('POST /executions/:id/retry', () => {
test('should fail due to missing API Key', testWithAPIKey('post', '/executions/1/retry', null));
test(
'should fail due to invalid API Key',
testWithAPIKey('post', '/executions/1/retry', 'abcXYZ'),
);
test('should retry an execution', async () => {
const mockedExecutionResponse = { status: 'waiting' } as any;
const executionServiceSpy = jest
.spyOn(Container.get(ExecutionService), 'retry')
.mockResolvedValue(mockedExecutionResponse);
const workflow = await createWorkflow({}, user1);
const execution = await createSuccessfulExecution(workflow);
const response = await authUser1Agent.post(`/executions/${execution.id}/retry`);
expect(response.statusCode).toBe(200);
expect(response.body).toEqual(mockedExecutionResponse);
executionServiceSpy.mockRestore();
});
test('should return 404 when execution is not found', async () => {
const nonExistentExecutionId = 99999999;
const response = await authUser1Agent.post(`/executions/${nonExistentExecutionId}/retry`);
expect(response.statusCode).toBe(404);
expect(response.body.message).toBe('Not Found');
});
test('should return 409 when trying to retry a queued execution', async () => {
const executionServiceSpy = jest
.spyOn(Container.get(ExecutionService), 'retry')
.mockRejectedValue(new QueuedExecutionRetryError());
const workflow = await createWorkflow({}, user1);
const execution = await createExecution({ status: 'new', finished: false }, workflow);
const response = await authUser1Agent.post(`/executions/${execution.id}/retry`);
expect(response.statusCode).toBe(409);
expect(response.body.message).toBe(
'Execution is queued to run (not yet started) so it cannot be retried',
);
executionServiceSpy.mockRestore();
});
test('should return 409 when trying to retry an aborted execution without execution data', async () => {
const executionServiceSpy = jest
.spyOn(Container.get(ExecutionService), 'retry')
.mockRejectedValue(new AbortedExecutionRetryError());
const workflow = await createWorkflow({}, user1);
const execution = await createExecution(
{
status: 'error',
finished: false,
data: JSON.stringify({ executionData: null }),
},
workflow,
);
const response = await authUser1Agent.post(`/executions/${execution.id}/retry`);
expect(response.statusCode).toBe(409);
expect(response.body.message).toBe(
'The execution was aborted before starting, so it cannot be retried',
);
executionServiceSpy.mockRestore();
});
test('should return 400 when trying to retry a finished execution', async () => {
const executionServiceSpy = jest
.spyOn(Container.get(ExecutionService), 'retry')
.mockRejectedValue(new UnexpectedError('The execution succeeded, so it cannot be retried.'));
const workflow = await createWorkflow({}, user1);
const execution = await createExecution(
{
status: 'success',
finished: true,
data: {} as any,
},
workflow,
);
const response = await authUser1Agent.post(`/executions/${execution.id}/retry`);
expect(response.statusCode).toBe(400);
expect(response.body.message).toBe('The execution succeeded, so it cannot be retried.');
executionServiceSpy.mockRestore();
});
});
describe('GET /executions', () => { describe('GET /executions', () => {
test('should fail due to missing API Key', testWithAPIKey('get', '/executions', null)); test('should fail due to missing API Key', testWithAPIKey('get', '/executions', null));

View File

@@ -245,8 +245,8 @@ async function retryOriginalExecution(execution: ExecutionSummary) {
async function retryExecution(execution: ExecutionSummary, loadWorkflow?: boolean) { async function retryExecution(execution: ExecutionSummary, loadWorkflow?: boolean) {
try { try {
const retryStatus = await executionsStore.retryExecution(execution.id, loadWorkflow); const retriedExecution = await executionsStore.retryExecution(execution.id, loadWorkflow);
const retryMessage = executionRetryMessage(retryStatus); const retryMessage = executionRetryMessage(retriedExecution.status);
if (retryMessage) { if (retryMessage) {
toast.showMessage(retryMessage); toast.showMessage(retryMessage);

View File

@@ -1,6 +1,6 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import type { IDataObject, ExecutionSummary, AnnotationVote, ExecutionStatus } from 'n8n-workflow'; import type { IDataObject, ExecutionSummary, AnnotationVote } from 'n8n-workflow';
import type { import type {
ExecutionFilterType, ExecutionFilterType,
ExecutionsQueryFilter, ExecutionsQueryFilter,
@@ -244,8 +244,8 @@ export const useExecutionsStore = defineStore('executions', () => {
); );
} }
async function retryExecution(id: string, loadWorkflow?: boolean): Promise<ExecutionStatus> { async function retryExecution(id: string, loadWorkflow?: boolean): Promise<IExecutionResponse> {
return await makeRestApiRequest( const retriedExecution = await makeRestApiRequest<IExecutionResponse>(
rootStore.restApiContext, rootStore.restApiContext,
'POST', 'POST',
`/executions/${id}/retry`, `/executions/${id}/retry`,
@@ -255,6 +255,7 @@ export const useExecutionsStore = defineStore('executions', () => {
} }
: undefined, : undefined,
); );
return retriedExecution;
} }
async function deleteExecutions(sendData: IExecutionDeleteFilter): Promise<void> { async function deleteExecutions(sendData: IExecutionDeleteFilter): Promise<void> {

View File

@@ -283,9 +283,9 @@ async function onExecutionRetry(payload: { id: string; loadWorkflow: boolean })
async function retryExecution(payload: { id: string; loadWorkflow: boolean }) { async function retryExecution(payload: { id: string; loadWorkflow: boolean }) {
try { try {
const retryStatus = await executionsStore.retryExecution(payload.id, payload.loadWorkflow); const retriedExecution = await executionsStore.retryExecution(payload.id, payload.loadWorkflow);
const retryMessage = executionRetryMessage(retryStatus); const retryMessage = executionRetryMessage(retriedExecution.status);
if (retryMessage) { if (retryMessage) {
toast.showMessage(retryMessage); toast.showMessage(retryMessage);