Skip to content

Commit 1315481

Browse files
authored
Merge pull request #191 from ShipSecAI/feature/error-retry-policies
feat: implement rich error handling with retry policies and E2E testing framework
2 parents 37b1763 + 6444def commit 1315481

File tree

86 files changed

+3173
-227
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

86 files changed

+3173
-227
lines changed

backend/src/database/schema/traces.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export const workflowTracesTable = pgTable(
1313
nodeRef: text('node_ref').notNull(),
1414
timestamp: timestamp('timestamp', { withTimezone: true }).notNull(),
1515
message: text('message'),
16-
error: text('error'),
16+
error: jsonb('error'),
1717
outputSummary: jsonb('output_summary'),
1818
level: text('level').notNull().default('info'),
1919
data: jsonb('data'),

backend/src/dsl/types.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,24 @@ export const WorkflowActionSchema = z.object({
1414
}),
1515
)
1616
.default({}),
17+
retryPolicy: z
18+
.object({
19+
maxAttempts: z.number().int().optional(),
20+
initialIntervalSeconds: z.number().optional(),
21+
maximumIntervalSeconds: z.number().optional(),
22+
backoffCoefficient: z.number().optional(),
23+
nonRetryableErrorTypes: z.array(z.string()).optional(),
24+
errorTypePolicies: z
25+
.record(
26+
z.string(),
27+
z.object({
28+
retryable: z.boolean().optional(),
29+
retryDelayMs: z.number().optional(),
30+
}),
31+
)
32+
.optional(),
33+
})
34+
.optional(),
1735
});
1836

1937
export type WorkflowAction = z.infer<typeof WorkflowActionSchema>;

backend/src/events/event-ingest.service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ interface KafkaTraceEventPayload {
1313
timestamp: string;
1414
level: string;
1515
message?: string;
16-
error?: string;
16+
error?: unknown;
1717
outputSummary?: unknown;
1818
data?: Record<string, unknown> | null;
1919
sequence: number;

backend/src/trace/trace.repository.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export interface PersistedTraceEvent {
2121
sequence: number;
2222
level: string;
2323
message?: string;
24-
error?: string;
24+
error?: unknown;
2525
outputSummary?: unknown;
2626
data?: Record<string, unknown> | null;
2727
}

backend/src/trace/trace.service.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ export class TraceService {
5656
timestamp: Date;
5757
type: PersistedTraceEventType;
5858
message: string | null;
59-
error: string | null;
59+
error: unknown;
6060
outputSummary: unknown | null;
6161
level: string;
6262
data: unknown | null;
@@ -77,7 +77,7 @@ export class TraceService {
7777
level,
7878
timestamp: record.timestamp.toISOString(),
7979
message: record.message ?? undefined,
80-
error: record.error ? { message: record.error } : undefined,
80+
error: this.toTraceError(record.error),
8181
outputSummary,
8282
};
8383

@@ -154,6 +154,42 @@ export class TraceService {
154154
return { payload, metadata };
155155
}
156156

157+
private toTraceError(error: unknown): TraceEventPayload['error'] {
158+
if (!error) {
159+
return undefined;
160+
}
161+
162+
if (typeof error === 'string') {
163+
return { message: error };
164+
}
165+
166+
if (typeof error === 'object' && error !== null) {
167+
const errObj = error as Record<string, unknown>;
168+
169+
// Extract fieldErrors if present and valid
170+
let fieldErrors: Record<string, string[]> | undefined;
171+
if ('fieldErrors' in errObj && errObj.fieldErrors !== null && typeof errObj.fieldErrors === 'object') {
172+
const fieldErrorsObj = errObj.fieldErrors as Record<string, unknown>;
173+
const isValidFieldErrors = Object.values(fieldErrorsObj).every(
174+
(value) => Array.isArray(value) && value.every((item) => typeof item === 'string')
175+
);
176+
if (isValidFieldErrors) {
177+
fieldErrors = fieldErrorsObj as Record<string, string[]>;
178+
}
179+
}
180+
181+
return {
182+
message: typeof errObj.message === 'string' ? errObj.message : String(error),
183+
type: typeof errObj.type === 'string' ? errObj.type : undefined,
184+
stack: typeof errObj.stack === 'string' ? errObj.stack : undefined,
185+
details: this.toRecord(errObj.details),
186+
fieldErrors,
187+
};
188+
}
189+
190+
return { message: String(error) };
191+
}
192+
157193
private parseMetadata(metadataRaw: unknown): TraceEventMetadata | undefined {
158194
if (!metadataRaw || typeof metadataRaw !== 'object' || Array.isArray(metadataRaw)) {
159195
return undefined;

backend/src/workflows/workflows.controller.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,18 @@ const traceErrorSchema = {
129129
message: { type: 'string' },
130130
stack: { type: 'string' },
131131
code: { type: 'string' },
132+
type: { type: 'string' },
133+
details: {
134+
type: 'object',
135+
additionalProperties: true,
136+
},
137+
fieldErrors: {
138+
type: 'object',
139+
additionalProperties: {
140+
type: 'array',
141+
items: { type: 'string' },
142+
},
143+
},
132144
},
133145
additionalProperties: false,
134146
};

backend/src/workflows/workflows.service.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -729,7 +729,22 @@ export class WorkflowsService {
729729
);
730730

731731
const nodeOverrides = options.nodeOverrides ?? {};
732-
const definitionWithOverrides = this.applyNodeOverrides(compiledDefinition, nodeOverrides);
732+
let definitionWithOverrides = this.applyNodeOverrides(compiledDefinition, nodeOverrides);
733+
734+
// Inject retry policies from component registry
735+
definitionWithOverrides = {
736+
...definitionWithOverrides,
737+
actions: definitionWithOverrides.actions.map((action) => {
738+
const component = componentRegistry.get(action.componentId);
739+
if (component?.retryPolicy) {
740+
return {
741+
...action,
742+
retryPolicy: component.retryPolicy,
743+
};
744+
}
745+
return action;
746+
}),
747+
};
733748
const normalizedKey = this.normalizeIdempotencyKey(options.idempotencyKey);
734749
const runId = options.runId ?? (normalizedKey ? this.runIdFromIdempotencyKey(normalizedKey) : `shipsec-run-${randomUUID()}`);
735750
const triggerMetadata = options.trigger ?? this.buildEntryPointTriggerMetadata(auth);

bun.lock

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

bunfig.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ members = ["frontend", "backend", "worker", "packages/**"]
33

44
[test]
55
preload = ["./test/setup.ts"]
6+
coveragePathIgnorePatterns = ["e2e-tests/**"]

e2e-tests/README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# E2E Tests
2+
3+
End-to-end tests for workflow execution with real backend, worker, and infrastructure.
4+
5+
## Prerequisites
6+
7+
Local development environment must be running:
8+
```bash
9+
docker compose -p shipsec up -d
10+
pm2 start pm2.config.cjs
11+
```
12+
13+
## Running Tests
14+
15+
```bash
16+
bun test:e2e
17+
```
18+
19+
Tests are skipped if services aren't available. Set `RUN_E2E=true` to enable.

0 commit comments

Comments
 (0)