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
6 changes: 2 additions & 4 deletions chat-client/src/client/mcpMynahUi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,8 @@ describe('McpMynahUi', () => {
assert.strictEqual(callArgs.detailedList.header.description, 'Test Description')
assert.deepStrictEqual(callArgs.detailedList.header.status, { status: 'success' })

// Verify the actions in the header
assert.strictEqual(callArgs.detailedList.header.actions.length, 2)
assert.strictEqual(callArgs.detailedList.header.actions[0].id, 'add-new-mcp')
assert.strictEqual(callArgs.detailedList.header.actions[1].id, 'refresh-mcp-list')
// Verify the actions in the header (no default actions are added when header is provided)
assert.strictEqual(callArgs.detailedList.header.actions.length, 0)

// Verify the list structure
assert.strictEqual(callArgs.detailedList.list.length, 1)
Expand Down
19 changes: 5 additions & 14 deletions chat-client/src/client/mcpMynahUi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,20 +272,11 @@ export class McpMynahUi {
title: params.header.title,
description: params.header.description,
status: params.header.status,
actions: [
{
id: MCP_IDS.ADD_NEW,
icon: toMynahIcon('plus'),
status: 'clear',
description: 'Add new MCP',
},
{
id: MCP_IDS.REFRESH_LIST,
icon: toMynahIcon('refresh'),
status: 'clear',
description: 'Refresh MCP servers',
},
],
actions:
params.header.actions?.map(action => ({
...action,
icon: action.icon ? toMynahIcon(action.icon) : undefined,
})) || [],
}
: undefined,
filterOptions: params.filterOptions?.map(filter => ({
Expand Down
1 change: 1 addition & 0 deletions core/aws-lsp-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ export * as workspaceUtils from './util/workspaceUtils'
export * as processUtils from './util/processUtils'
export * as collectionUtils from './util/collectionUtils'
export * as loggingUtils from './util/loggingUtils'
export * as retryUtils from './util/retryUtils'
120 changes: 120 additions & 0 deletions core/aws-lsp-core/src/util/retryUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/*!
* Copyright Amazon.com, Inc. or its affiliates.
* All Rights Reserved. SPDX-License-Identifier: Apache-2.0
*/

import { expect } from 'chai'
import * as sinon from 'sinon'
import { retryWithBackoff, DEFAULT_MAX_RETRIES, DEFAULT_BASE_DELAY } from './retryUtils'

describe('retryUtils', () => {
let clock: sinon.SinonFakeTimers

beforeEach(() => {
clock = sinon.useFakeTimers()
})

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

describe('retryWithBackoff', () => {
it('should return result on first success', async () => {
const fn = sinon.stub().resolves('success')

const result = await retryWithBackoff(fn)

expect(result).to.equal('success')
expect(fn.callCount).to.equal(1)
})

it('should retry on retryable errors', async () => {
const fn = sinon.stub()
fn.onFirstCall().rejects({ code: 'ThrottlingException' })
fn.onSecondCall().resolves('success')

const promise = retryWithBackoff(fn)
await clock.tickAsync(DEFAULT_BASE_DELAY)
const result = await promise

expect(result).to.equal('success')
expect(fn.callCount).to.equal(2)
})

it('should not retry on non-retryable client errors', async () => {
const error = { statusCode: 404 }
const fn = sinon.stub().rejects(error)

try {
await retryWithBackoff(fn)
expect.fail('Expected function to throw')
} catch (e) {
expect(e).to.equal(error)
}
expect(fn.callCount).to.equal(1)
})

it('should retry on server errors', async () => {
const fn = sinon.stub()
fn.onFirstCall().rejects({ statusCode: 500 })
fn.onSecondCall().resolves('success')

const promise = retryWithBackoff(fn)
await clock.tickAsync(DEFAULT_BASE_DELAY)
const result = await promise

expect(result).to.equal('success')
expect(fn.callCount).to.equal(2)
})

it('should use exponential backoff by default', async () => {
const fn = sinon.stub()
const error = { code: 'ThrottlingException' }
fn.onFirstCall().rejects(error)
fn.onSecondCall().rejects(error)

const promise = retryWithBackoff(fn)

// First retry after baseDelay * 1
await clock.tickAsync(DEFAULT_BASE_DELAY)
// Second retry after baseDelay * 2
await clock.tickAsync(DEFAULT_BASE_DELAY * 2)

try {
await promise
expect.fail('Expected function to throw')
} catch (e) {
expect(e).to.equal(error)
}
expect(fn.callCount).to.equal(DEFAULT_MAX_RETRIES)
})

it('should respect custom maxRetries', async () => {
const error = { code: 'ThrottlingException' }
const fn = sinon.stub().rejects(error)

try {
await retryWithBackoff(fn, { maxRetries: 1 })
expect.fail('Expected function to throw')
} catch (e) {
expect(e).to.equal(error)
}
expect(fn.callCount).to.equal(1)
})

it('should use custom isRetryable function', async () => {
const error = { custom: 'error' }
const fn = sinon.stub().rejects(error)
const isRetryable = sinon.stub().returns(false)

try {
await retryWithBackoff(fn, { isRetryable })
expect.fail('Expected function to throw')
} catch (e) {
expect(e).to.equal(error)
}
expect(fn.callCount).to.equal(1)
expect(isRetryable.calledWith(error)).to.equal(true)
})
})
})
77 changes: 77 additions & 0 deletions core/aws-lsp-core/src/util/retryUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/*!
* Copyright Amazon.com, Inc. or its affiliates.
* All Rights Reserved. SPDX-License-Identifier: Apache-2.0
*/

// Default retry configuration constants
export const DEFAULT_MAX_RETRIES = 2
export const DEFAULT_BASE_DELAY = 500
export const DEFAULT_EXPONENTIAL_BACKOFF = true

// HTTP status code constants
const CLIENT_ERROR_MIN = 400
const CLIENT_ERROR_MAX = 500
const INTERNAL_SERVER_ERROR = 500
const SERVICE_UNAVAILABLE = 503

// AWS error code constants
const THROTTLING_EXCEPTION = 'ThrottlingException'
const INTERNAL_SERVER_EXCEPTION = 'InternalServerException'

export interface RetryOptions {
/** Maximum number of retry attempts (default: DEFAULT_MAX_RETRIES) */
maxRetries?: number
/** Base delay in milliseconds (default: DEFAULT_BASE_DELAY) */
baseDelay?: number
/** Whether to use exponential backoff (default: DEFAULT_EXPONENTIAL_BACKOFF) */
exponentialBackoff?: boolean
/** Custom function to determine if an error is retryable */
isRetryable?: (error: any) => boolean
}

/**
* Default AWS error retry logic
*/
function defaultIsRetryable(error: any): boolean {
const errorCode = error.code || error.name
const statusCode = error.statusCode

// Fast fail on non-retryable client errors (except throttling)
if (statusCode >= CLIENT_ERROR_MIN && statusCode < CLIENT_ERROR_MAX && errorCode !== THROTTLING_EXCEPTION) {
return false
}

// Retry on throttling, server errors, and specific status codes
return (
errorCode === THROTTLING_EXCEPTION ||
errorCode === INTERNAL_SERVER_EXCEPTION ||
statusCode === INTERNAL_SERVER_ERROR ||
statusCode === SERVICE_UNAVAILABLE
)
}

/**
* Executes a function with retry logic and exponential backoff
*/
export async function retryWithBackoff<T>(fn: () => Promise<T>, options: RetryOptions = {}): Promise<T> {
const {
maxRetries = DEFAULT_MAX_RETRIES,
baseDelay = DEFAULT_BASE_DELAY,
exponentialBackoff = DEFAULT_EXPONENTIAL_BACKOFF,
isRetryable = defaultIsRetryable,
} = options

for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await fn()
} catch (error: any) {
if (!isRetryable(error) || attempt === maxRetries - 1) {
throw error
}

const delay = exponentialBackoff ? baseDelay * (attempt + 1) : baseDelay
await new Promise(resolve => setTimeout(resolve, delay))
}
}
throw new Error('Retry failed')
}
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,37 @@
],
"documentation": "<p>API to get code transformation status.</p>"
},
"GetProfile": {
"name": "GetProfile",
"http": {
"method": "POST",
"requestUri": "/"
},
"input": {
"shape": "GetProfileRequest"
},
"output": {
"shape": "GetProfileResponse"
},
"errors": [
{
"shape": "ThrottlingException"
},
{
"shape": "ResourceNotFoundException"
},
{
"shape": "InternalServerException"
},
{
"shape": "ValidationException"
},
{
"shape": "AccessDeniedException"
}
],
"documentation": "<p>Get the requested CodeWhisperer profile.</p>"
},
"GetUsageLimits": {
"name": "GetUsageLimits",
"http": {
Expand Down Expand Up @@ -3126,6 +3157,24 @@
}
}
},
"GetProfileRequest": {
"type": "structure",
"required": ["profileArn"],
"members": {
"profileArn": {
"shape": "ProfileArn"
}
}
},
"GetProfileResponse": {
"type": "structure",
"required": ["profile"],
"members": {
"profile": {
"shape": "ProfileInfo"
}
}
},
"GetTaskAssistCodeGenerationRequest": {
"type": "structure",
"required": ["conversationId", "codeGenerationId"],
Expand Down Expand Up @@ -3787,6 +3836,15 @@
"type": "long",
"box": true
},
"MCPConfiguration": {
"type": "structure",
"required": ["toggle"],
"members": {
"toggle": {
"shape": "OptInFeatureToggle"
}
}
},
"MemoryEntry": {
"type": "structure",
"required": ["id", "memoryEntryString", "metadata"],
Expand Down Expand Up @@ -3995,6 +4053,9 @@
},
"workspaceContext": {
"shape": "WorkspaceContext"
},
"mcpConfiguration": {
"shape": "MCPConfiguration"
}
}
},
Expand Down Expand Up @@ -4189,6 +4250,30 @@
}
}
},
"ProfileInfo": {
"type": "structure",
"required": ["arn"],
"members": {
"arn": {
"shape": "ProfileArn"
},
"profileName": {
"shape": "ProfileName"
},
"description": {
"shape": "ProfileDescription"
},
"status": {
"shape": "ProfileStatus"
},
"profileType": {
"shape": "ProfileType"
},
"optInFeatures": {
"shape": "OptInFeatures"
}
}
},
"ProfileArn": {
"type": "string",
"max": 950,
Expand Down
Loading
Loading