Skip to content

Commit c089d01

Browse files
author
thyldrm
committed
fix(ci): implement client-side polling to prevent serverless timeout
- Refactor runScan() to always use wait:false for backend calls - Add pollScanCompletion() private method for client-side polling - Avoid Netlify/Vercel serverless function timeouts (10s limit) - Polling now runs on CI/CD runner, supporting 12+ hour scans - Backend API calls return in < 5 seconds, no serverless timeout - Verbose logging for polling progress BREAKING CHANGE: Backend always receives wait:false, polling moved to client Fixes: 504 Gateway Timeout errors in GitHub Actions and Azure DevOps Architecture: Serverless-friendly client-side polling pattern Version: 1.12.1 → 1.12.2
1 parent c741060 commit c089d01

File tree

2 files changed

+78
-9
lines changed

2 files changed

+78
-9
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@codethreat/appsec-cli",
3-
"version": "1.12.1",
3+
"version": "1.12.2",
44
"description": "CodeThreat AppSec CLI for CI/CD integration and automated security scanning",
55
"main": "dist/index.js",
66
"types": "dist/index.d.ts",

src/lib/api-client.ts

Lines changed: 77 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,9 @@ export class CodeThreatApiClient {
154154
}
155155

156156
/**
157-
* Run scan (synchronous or asynchronous)
157+
* Run scan with client-side polling (serverless-friendly)
158+
* This method always starts an async scan and polls from the client side
159+
* to avoid serverless function timeouts (Netlify/Vercel limits)
158160
*/
159161
async runScan(options: {
160162
repositoryId: string;
@@ -176,34 +178,101 @@ export class CodeThreatApiClient {
176178
const requestBody: any = {
177179
repositoryId: options.repositoryId,
178180
organizationSlug: organizationSlug,
179-
scanTypes: options.scanTypes
181+
scanTypes: options.scanTypes,
182+
wait: false, // ALWAYS false - we do client-side polling to avoid serverless timeouts
180183
};
181184

182185
// Only add optional fields if they have values
183186
if (options.branch) requestBody.branch = options.branch;
184-
if (options.wait !== undefined) requestBody.wait = options.wait;
185-
if (options.timeout !== undefined) requestBody.timeout = options.timeout;
186-
if (options.pollInterval !== undefined) requestBody.pollInterval = options.pollInterval;
187187
if (options.scanTrigger) requestBody.scanTrigger = options.scanTrigger;
188188
if (options.pullRequestId) requestBody.pullRequestId = options.pullRequestId;
189189
if (options.commitSha) requestBody.commitSha = options.commitSha;
190190
if (options.metadata) requestBody.metadata = options.metadata;
191191

192-
193192
// Validate organizationSlug is present
194193
if (!requestBody.organizationSlug) {
195194
throw new Error('Organization slug is required. Please set CT_ORG_SLUG environment variable or provide organizationSlug parameter.');
196195
}
197196

197+
// Start async scan (returns immediately, no serverless timeout)
198198
const response = await this.client.post<ApiResponse<ScanRunResponse>>(
199199
'/api/v1/scans/run',
200200
requestBody,
201201
{
202-
timeout: options.wait ? (options.timeout || 43200) * 1000 + 30000 : 30000, // Default 12 hours for long scans, add 30s buffer for API processing
202+
timeout: 30000, // 30 seconds - only for starting scan, not waiting
203203
}
204204
);
205205

206-
return this.handleResponse(response);
206+
const scanResponse = this.handleResponse(response);
207+
208+
// If wait=true, do client-side polling
209+
if (options.wait) {
210+
return await this.pollScanCompletion(
211+
scanResponse.scan.id,
212+
options.timeout || 43200, // Default 12 hours
213+
options.pollInterval || 30 // Default 30 seconds
214+
);
215+
}
216+
217+
return scanResponse;
218+
}
219+
220+
/**
221+
* Poll scan status until completion (client-side polling)
222+
* This avoids serverless function timeouts by polling from the client
223+
*/
224+
private async pollScanCompletion(
225+
scanId: string,
226+
timeoutSeconds: number,
227+
pollIntervalSeconds: number
228+
): Promise<ScanRunResponse> {
229+
const startTime = Date.now();
230+
const timeoutMs = timeoutSeconds * 1000;
231+
const pollIntervalMs = pollIntervalSeconds * 1000;
232+
233+
if (this.config.verbose) {
234+
console.log(`⏳ Polling scan ${scanId} (timeout: ${timeoutSeconds}s, interval: ${pollIntervalSeconds}s)`);
235+
}
236+
237+
while (Date.now() - startTime < timeoutMs) {
238+
// Get current scan status
239+
const statusResponse = await this.getScanStatus(scanId, false);
240+
241+
if (statusResponse.scan.status === 'COMPLETED') {
242+
if (this.config.verbose) {
243+
console.log(`✅ Scan completed in ${Math.round((Date.now() - startTime) / 1000)}s`);
244+
}
245+
246+
// Return full response with results
247+
return {
248+
scan: statusResponse.scan,
249+
synchronous: true,
250+
results: {
251+
total: statusResponse.results.violationCount,
252+
critical: statusResponse.results.summary.critical,
253+
high: statusResponse.results.summary.high,
254+
medium: statusResponse.results.summary.medium,
255+
low: statusResponse.results.summary.low,
256+
},
257+
duration: Math.round((Date.now() - startTime) / 1000),
258+
};
259+
}
260+
261+
if (statusResponse.scan.status === 'FAILED') {
262+
throw new Error(`Scan failed during execution`);
263+
}
264+
265+
if (this.config.verbose) {
266+
const elapsed = Math.round((Date.now() - startTime) / 1000);
267+
console.log(`⏳ Scan status: ${statusResponse.scan.status} (${elapsed}s elapsed)`);
268+
}
269+
270+
// Wait before next poll
271+
await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
272+
}
273+
274+
// Timeout reached
275+
throw new Error(`Scan timeout after ${timeoutSeconds} seconds. Scan ID: ${scanId}`);
207276
}
208277

209278
/**

0 commit comments

Comments
 (0)