Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions packages/zcli-connectors/src/commands/connectors/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as chalk from 'chalk'
import * as ora from 'ora'
import { runValidationChecks } from '../../lib/validations'
import { createConnector, uploadConnectorPackage } from '../../lib/publish/publish'
import { pollProvisioningStatus } from '../../lib/publish/poller'

export default class Publish extends Command {
static description = 'publish a connector'
Expand Down Expand Up @@ -95,11 +96,24 @@ export default class Publish extends Command {
private async publishConnector (
path: string
): Promise<void> {
const spinner = ora('Publishing connector...').start()
let spinner = ora('Publishing connector...').start()
try {
const { uploadUrl, connectorName } = await createConnector(path)
const { uploadUrl, connectorName, jobId } = await createConnector(path)
await uploadConnectorPackage(path, uploadUrl, connectorName)
spinner.succeed(chalk.green('Publish complete'))
spinner.succeed(chalk.green('Upload complete'))

spinner = ora('Waiting for connector provisioning...').start()
const { status: finalStatus, reason } = await pollProvisioningStatus(connectorName, jobId)

if (finalStatus === 'SUCCESS') {
spinner.succeed(chalk.green('Connector provisioned successfully!'))
} else if (finalStatus === 'FAILED') {
spinner.fail(chalk.red('Connector provisioning failed'))
throw new Error(`Connector provisioning failed: ${reason ?? 'Unknown reason'}`)
} else if (finalStatus === 'ABORTED') {
spinner.fail(chalk.yellow('Connector provisioning was aborted'))
throw new Error(`Connector provisioning was aborted: ${reason ?? 'Unknown reason'}`)
}
} catch (error) {
spinner.fail(chalk.red('Publish failed'))
throw error
Expand Down
231 changes: 231 additions & 0 deletions packages/zcli-connectors/src/lib/publish/poller.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
/* eslint-disable @typescript-eslint/no-explicit-any */

import { expect } from 'chai'
import * as sinon from 'sinon'
import { request } from '@zendesk/zcli-core'
import { getAdaptivePollIntervalMs, pollProvisioningStatus } from './poller'

describe('poller', () => {
afterEach(() => {
sinon.restore()
})

describe('getAdaptivePollIntervalMs', () => {
it('should return proper poll intervals', () => {
const now = 1000000000
const realDateNow = Date.now
Date.now = () => now

try {
expect(getAdaptivePollIntervalMs(now)).to.equal(5000)
expect(getAdaptivePollIntervalMs(now - 30000)).to.equal(5000)
expect(getAdaptivePollIntervalMs(now - 60000)).to.equal(30000)
expect(getAdaptivePollIntervalMs(now - 300000)).to.equal(60000)
} finally {
Date.now = realDateNow
}
})
})

describe('pollProvisioningStatus', () => {
let requestStub: sinon.SinonStub
let clock: sinon.SinonFakeTimers

beforeEach(() => {
requestStub = sinon.stub(request, 'requestAPI')
clock = sinon.useFakeTimers()
})

afterEach(() => {
clock.restore()
})

it('should return immediately when status is SUCCESS', async () => {
requestStub.resolves({
status: 200,
data: { status: 'SUCCESS', reason: 'Deployment completed' }
})

const result = await pollProvisioningStatus('test-connector', 'job-123')

expect(result).to.deep.equal({
status: 'SUCCESS',
reason: 'Deployment completed'
})
// eslint-disable-next-line no-unused-expressions
expect(requestStub.calledOnce).to.be.true
expect(requestStub.firstCall.args[0]).to.equal('/flowstate/connectors/private/test-connector/provisioning_status/job-123')
})

it('should return immediately when status is FAILED', async () => {
requestStub.resolves({
status: 200,
data: { status: 'FAILED', reason: 'Invalid configuration' }
})

const result = await pollProvisioningStatus('test-connector', 'job-123')

expect(result).to.deep.equal({
status: 'FAILED',
reason: 'Invalid configuration'
})
// eslint-disable-next-line no-unused-expressions
expect(requestStub.calledOnce).to.be.true
})

it('should return immediately when status is ABORTED', async () => {
requestStub.resolves({
status: 200,
data: { status: 'ABORTED', reason: 'aborted the previous operation' }
})

const result = await pollProvisioningStatus('test-connector', 'job-123')

expect(result).to.deep.equal({
status: 'ABORTED',
reason: 'aborted the previous operation'
})
// eslint-disable-next-line no-unused-expressions
expect(requestStub.called).to.be.true
})

it('should poll multiple times until SUCCESS', async () => {
requestStub
.onFirstCall().resolves({
status: 200,
data: { status: 'PENDING_UPLOAD', reason: '' }
})
.onSecondCall().resolves({
status: 200,
data: { status: 'PENDING_VALIDATION', reason: '' }
})
.onThirdCall().resolves({
status: 200,
data: { status: 'SUCCESS', reason: '' }
})

const pollPromise = pollProvisioningStatus('test-connector', 'job-123')

// Let the first request complete
await Promise.resolve()

// Advance time to trigger first poll interval
await clock.tickAsync(5000)

// Advance time to trigger second poll interval
await clock.tickAsync(5000)

const result = await pollPromise

expect(result).to.deep.equal({ status: 'SUCCESS', reason: '' })
expect(requestStub.callCount).to.equal(3)
})

it('should timeout after 5 minutes', async () => {
requestStub.resolves({
status: 200,
data: { status: 'PENDING_UPLOAD', reason: '' }
})

const pollPromise = pollProvisioningStatus('test-connector', 'job-123')

// Advance time by 5 minutes + 1 second
await clock.tickAsync(5 * 60 * 1000 + 1000)

try {
await pollPromise
expect.fail('Should have thrown timeout error')
} catch (error) {
expect(error).to.be.instanceOf(Error)
expect((error as Error).message).to.equal('Provisioning status polling timed out after 5 minutes')
}
})

it('should retry non-200 HTTP responses up to 3 times', async () => {
requestStub
.onFirstCall().resolves({
status: 500,
data: { error: 'Internal server error' }
})
.onSecondCall().resolves({
status: 502,
data: { error: 'Bad gateway' }
})
.onThirdCall().resolves({
status: 503,
data: { error: 'Service unavailable' }
})
.onCall(3).resolves({
status: 500,
data: { error: 'Still failing' }
})

const pollPromise = pollProvisioningStatus('test-connector', 'job-123')

await clock.tickAsync(5000) // First retry
await clock.tickAsync(10000) // Second retry
await clock.tickAsync(20000) // Third retry

try {
await pollPromise
expect.fail('Should have thrown error after retries')
} catch (error) {
expect(error).to.be.instanceOf(Error)
expect((error as Error).message).to.include('HTTP 500')
expect((error as Error).message).to.include('Still failing')
}

// Should have made 4 calls (1 initial + 3 retries)
expect(requestStub.callCount).to.equal(4)
})

it('should use exponential backoff for retries', async () => {
requestStub
.onFirstCall().resolves({
status: 500,
data: { error: 'Server error' }
})
.onSecondCall().resolves({
status: 200,
data: { status: 'SUCCESS', reason: '' }
})

const pollPromise = pollProvisioningStatus('test-connector', 'job-123')

// Let the first request fail and retry start
await Promise.resolve()

// First retry should wait 5 seconds (BASE_RETRY_DELAY_MS * 2^0)
await clock.tickAsync(5000)

const result = await pollPromise

expect(result).to.deep.equal({ status: 'SUCCESS', reason: '' })
expect(requestStub.callCount).to.equal(2)
})

it('should handle request API errors gracefully', async () => {
requestStub.rejects(new Error('Network error'))

try {
await pollProvisioningStatus('test-connector', 'job-123')
expect.fail('Should have thrown error')
} catch (error) {
expect(error).to.be.instanceOf(Error)
expect((error as Error).message).to.equal('Failed to check provisioning status: Network error')
}
})

it('should handle timeout errors specifically', async () => {
requestStub.rejects(new Error('Request timed out after 30 seconds'))

try {
await pollProvisioningStatus('test-connector', 'job-123')
expect.fail('Should have thrown error')
} catch (error) {
expect(error).to.be.instanceOf(Error)
expect((error as Error).message).to.equal('Failed to check provisioning status: Request timed out after 30 seconds')
}
})
})
})
123 changes: 123 additions & 0 deletions packages/zcli-connectors/src/lib/publish/poller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { request } from '@zendesk/zcli-core'

export type ProvisioningStatus = 'PENDING_UPLOAD' | 'PENDING_VALIDATION' | 'SUCCESS' | 'FAILED' | 'ABORTED'

interface ProvisioningStatusResponse {
status: ProvisioningStatus
reason?: string
}

export interface ProvisioningResult {
status: ProvisioningStatus
reason?: string
}

const POLLING_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes
const MAX_HTTP_RETRIES = 3
const BASE_RETRY_DELAY_MS = 5000 // 5 seconds base delay

/**
* Validates if the given status is a valid ProvisioningStatus
* @param status - The status to validate
* @returns True if the status is valid
*/
function isValidProvisioningStatus (status: any): status is ProvisioningStatus {
const validStatuses: ProvisioningStatus[] = ['PENDING_UPLOAD', 'PENDING_VALIDATION', 'SUCCESS', 'FAILED', 'ABORTED']
return typeof status === 'string' && validStatuses.includes(status as ProvisioningStatus)
}

/**
* Make an HTTP request with retry logic and exponential backoff
* @param endpoint - The API endpoint to call
* @returns Promise that resolves with the HTTP response
*/
async function makeRequestWithRetry (endpoint: string): Promise<any> {
let httpRetryCount = 0

while (httpRetryCount <= MAX_HTTP_RETRIES) {
const response = await request.requestAPI(endpoint, {
method: 'GET'
})

if (response.status === 200) {
return response
}

httpRetryCount++
if (httpRetryCount > MAX_HTTP_RETRIES) {
const errorDetails = response.data?.message || response.data?.error || JSON.stringify(response.data)
throw new Error(`HTTP ${response.status}: ${errorDetails}`)
}

// Exponential backoff: 5s, 10s, 20s for retries 1, 2, 3
const backoffDelay = BASE_RETRY_DELAY_MS * Math.pow(2, httpRetryCount - 1)
await new Promise(resolve => setTimeout(resolve, backoffDelay))
}
}

/**
* Get an adaptive polling interval that starts fast and backs off over time.
* Polls frequently at first for responsiveness, then less often to reduce load.
*
* @param startTime - The timestamp when polling started (Date.now())
* @returns The polling interval in milliseconds
*/
export function getAdaptivePollIntervalMs (startTime: number): number {
const timeElapsed = Date.now() - startTime
switch (true) {
case timeElapsed < 60000:
// every 5s for first 1min
return 5000
case timeElapsed < 300000:
// every 30s for 1min-5min
return 30000
default:
// every 1min for 5min+
return 60000
}
}

/**
* Poll the provisioning status of a connector
* @param connectorName - The name of the connector
* @param jobId - The job ID returned from createConnector
* @returns Promise that resolves when status is final (SUCCESS, FAILED, or ABORTED)
*/
export async function pollProvisioningStatus (
connectorName: string,
jobId: string
): Promise<ProvisioningResult> {
const startTime = Date.now()
const endpoint = `/flowstate/connectors/private/${connectorName}/provisioning_status/${jobId}`

while (true) {
const timeElapsed = Date.now() - startTime
if (timeElapsed >= POLLING_TIMEOUT_MS) {
throw new Error(`Provisioning status polling timed out after ${POLLING_TIMEOUT_MS / (60 * 1000)} minutes`)
}

try {
const response = await makeRequestWithRetry(endpoint)
const statusResponse: ProvisioningStatusResponse = response.data
const status = statusResponse.status

if (!isValidProvisioningStatus(status)) {
throw new Error(`Received unexpected provisioning status: '${status}'. Expected one of: PENDING_UPLOAD, PENDING_VALIDATION, SUCCESS, FAILED, ABORTED`)
}

if (status === 'SUCCESS' || status === 'FAILED' || status === 'ABORTED') {
return { status, reason: statusResponse.reason }
}

const pollInterval = getAdaptivePollIntervalMs(startTime)
await new Promise(resolve => setTimeout(resolve, pollInterval))
} catch (error) {
if (error instanceof Error && error.message.includes('polling timed out')) {
throw new Error(`Provisioning status polling timed out after ${POLLING_TIMEOUT_MS / (60 * 1000)} minutes`)
}

const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
throw new Error(`Failed to check provisioning status: ${errorMessage}`)
}
}
}
Loading
Loading