Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import nock from 'nock'
import { createTestIntegration, IntegrationError } from '@segment/actions-core'
import { createTestIntegration, IntegrationError, RetryableError } from '@segment/actions-core'
import Destination from '../index'
import { BASE_URL, TOKEN_URL } from '../constants'

Expand Down Expand Up @@ -102,6 +102,49 @@ describe('Bing Ads Audiences', () => {

await expect(testDestination.createAudience(createAudienceInput)).rejects.toThrow(IntegrationError)
})

it('should surface an unrecognized PartialError with Bing ErrorCode and Message', async () => {
const partialErrorResponse = {
AudienceIds: [],
PartialErrors: [
{
ErrorCode: 'CampaignServiceDuplicateAudienceName',
Message: 'An audience with this name already exists.',
Code: 4848,
Index: 0
}
]
}
nock(BASE_URL).post('/Audiences').reply(200, partialErrorResponse)

await expect(testDestination.createAudience(createAudienceInput)).rejects.toThrowError(
new IntegrationError(
'Failed to create audience: CampaignServiceDuplicateAudienceName: An audience with this name already exists.',
'CampaignServiceDuplicateAudienceName',
400
)
)
Comment on lines +120 to +126
})

it('should throw an IntegrationError surfacing Bing status and body on a non-2xx error', async () => {
nock(BASE_URL).post('/Audiences').reply(400, 'Bad Request')

const error = await testDestination.createAudience(createAudienceInput).catch((e) => e)
expect(error).toBeInstanceOf(IntegrationError)
expect(error).not.toBeInstanceOf(RetryableError)
expect(error.message).toBe('Failed to create audience. Microsoft Bing Ads returned HTTP 400: Bad Request')
expect(error.code).toBe('CREATE_AUDIENCE_FAILED')
expect(error.status).toBe(400)
})

it('should throw a RetryableError with a 5xx status so Segment retries', async () => {
nock(BASE_URL).post('/Audiences').reply(500, '')

const error = await testDestination.createAudience(createAudienceInput).catch((e) => e)
expect(error).toBeInstanceOf(RetryableError)
expect(error.message).toBe('Failed to create audience. Microsoft Bing Ads returned HTTP 500: no response body')
expect(error.status).toBe(500)
})
})

describe('getAudience', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,34 @@
import {
IntegrationError,
PayloadValidationError,
RetryableError,
AudienceDestinationDefinition,
InvalidAuthenticationError,
HTTPError,
ErrorCodes
} from '@segment/actions-core'
import type { Settings } from './generated-types'
import syncAudiences from './syncAudiences'
import { CreateAudienceResponse, CreateAudienceRequest, GetAudienceResponse, RefreshTokenResponse } from './types'
import { BASE_URL, TOKEN_URL } from './constants'

/**
* Safely reads the body of an error response from the Bing Ads API for logging.
*
* Bing can return an empty or non-JSON body on failures, so this never throws: it returns the
* raw text (which may be empty) and falls back to an empty string if the body can't be read.
*/
const readResponseBody = async (response?: Response): Promise<string> => {
if (!response) {
return ''
}
try {
return (await response.text()) ?? ''
} catch {
return ''
}
}
Comment on lines +21 to +30

const destination: AudienceDestinationDefinition<Settings> = {
name: 'Ms Bing Ads Audiences',
slug: 'actions-ms-bing-ads-audiences',
Expand Down Expand Up @@ -95,21 +114,57 @@ const destination: AudienceDestinationDefinition<Settings> = {
]
}

const response: CreateAudienceResponse = await request(`${BASE_URL}/Audiences`, {
method: 'POST',
json
})
let response: CreateAudienceResponse
try {
response = await request(`${BASE_URL}/Audiences`, {
method: 'POST',
json
})
} catch (error) {
// The Bing Ads API can return a non-2xx (often an empty or non-JSON body) that the
// request client throws as an HTTPError. Without this, the raw error escapes untyped
// and the platform surfaces an opaque "500 / Bad Request" with no detail. Capture
// Bing's actual status and response body so the real cause is visible in logs.
if (error instanceof HTTPError) {
const status = error.response?.status
const body = await readResponseBody(error.response)
const message = `Failed to create audience. Microsoft Bing Ads returned HTTP ${status ?? 'unknown'}: ${
body || 'no response body'
}`
Comment on lines +131 to +133

// 5xx (and other server-side) failures are transient — let Segment retry them.
if (typeof status === 'number' && status >= 500) {
throw new RetryableError(message, status)
}
throw new IntegrationError(message, 'CREATE_AUDIENCE_FAILED', status ?? 400)
}
throw error
}

// Handle Terms and Conditions error from Bing Ads API
// Handle PartialErrors returned by the Bing Ads API. Bing returns a 200 with a
// PartialErrors array (rather than an HTTP error) for most create failures, so any
// error code here must be surfaced explicitly — otherwise it is silently swallowed
// and collapses into the opaque NO_AUDIENCE_ID error below.
if (response?.data?.PartialErrors?.length) {
const errorObj = response.data.PartialErrors[0]

if (errorObj?.ErrorCode === 'CustomerListTermsAndConditionsNotAccepted') {
throw new IntegrationError(
"The Customer Match 'Terms And Conditions' are not yet Accepted in the Microsoft Advertising web UI. Please create a Customer List in the Microsoft Advertising UI to accept the terms.",
'TERMS_NOT_ACCEPTED',
400
)
}

// Surface every other PartialError with Bing's own ErrorCode and Message so the
// real cause (e.g. a duplicate audience name) is visible instead of NO_AUDIENCE_ID.
throw new IntegrationError(
`Failed to create audience: ${errorObj?.ErrorCode ?? 'UnknownError'}: ${
errorObj?.Message ?? 'No error message provided'
}`,
errorObj?.ErrorCode ?? 'CREATE_AUDIENCE_FAILED',
400
)
}

// Extract the created audience ID
Expand Down
Loading