Improves SMS error handling for common scenarios (#265)

This commit is contained in:
Chris Anderson 2023-09-16 15:04:44 -07:00 committed by GitHub
parent c6b57abbde
commit 9ec841b149
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 53 additions and 12 deletions

View file

@ -66,7 +66,7 @@ export default class NexmoTextProvider extends TextProvider {
// Nexmo always returns 200 even for error
if (responseMessage.status !== '0') {
throw new TextError('nexmo', `Request failed with status: ${responseMessage.status}, error: ${responseMessage['error-text']}`)
throw new TextError(this.type, this.phone_number, `Request failed with status: ${responseMessage.status}, error: ${responseMessage['error-text']}`)
} else {
return {
message,
@ -75,7 +75,7 @@ export default class NexmoTextProvider extends TextProvider {
}
}
} else {
throw new TextError('nexmo', `Request failed with status ${response.status}`)
throw new TextError(this.type, this.phone_number, `Request failed with status ${response.status}`)
}
}

View file

@ -1,5 +1,6 @@
import { ExternalProviderParams, ProviderControllers, ProviderSchema } from '../Provider'
import { createController } from '../ProviderService'
import TextError from './TextError'
import { InboundTextMessage, TextMessage, TextResponse } from './TextMessage'
import { TextProvider } from './TextProvider'
@ -66,7 +67,10 @@ export default class PlivoTextProvider extends TextProvider {
response: responseBody.message_uuid[0],
}
} else {
throw new Error(response.status === 401 ? await response.text() : (await response.json()).error)
const error = response.status === 401
? await response.text()
: (await response.json()).error
throw new TextError(this.type, this.phone_number, error)
}
}

View file

@ -3,6 +3,8 @@ import { Variables } from '../../render'
import { TextProvider } from './TextProvider'
import { InboundTextMessage } from './TextMessage'
import { UserEvent } from '../../users/UserEvent'
import { UnsubscribeTextError } from './TextError'
import { unsubscribe } from '../../subscriptions/SubscriptionService'
const TEXT_SEGMENT_LENGTH = 160
@ -19,10 +21,20 @@ export default class TextChannel {
async send(template: TextTemplate, variables: Variables) {
if (!variables.user.phone) throw new Error('Unable to send a text message to a user with no phone number.')
const message = await this.build(template, variables)
await this.provider.send({
to: variables.user.phone,
...message,
})
try {
await this.provider.send({
to: variables.user.phone,
...message,
})
} catch (error: any) {
// If for some reason we are getting an unsubscribe error
// force unsubscribe the user from this subscription type
if (error instanceof UnsubscribeTextError) {
unsubscribe(variables.user.id, variables.context.subscription_id)
}
throw error
}
}
async build(template: TextTemplate, variables: Variables): Promise<CompiledText> {

View file

@ -1,6 +1,14 @@
export default class TextError extends Error {
constructor(type: string, message: string) {
phone: string
constructor(type: string, phone: string, message: string) {
super(`Text Error: ${type}: ${message}`)
this.phone = phone
Error.captureStackTrace(this, TextError)
}
}
export class UnsubscribeTextError extends TextError { }
export class UndeliverableTextError extends TextError { }
export class RateLimitTextError extends TextError { }

View file

@ -41,7 +41,16 @@ export default class TextJob extends Job {
const isReady = await prepareSend(channel, data, raw, segments)
if (!isReady) return
await channel.send(template, data)
try {
await channel.send(template, data)
} catch (error: any) {
await updateSendState({
campaign,
user,
user_step_id: trigger.user_step_id,
state: 'failed',
})
}
// Update send record
await updateSendState({

View file

@ -2,6 +2,7 @@ import App from '../../app'
import { encodeHashid } from '../../utilities'
import { ExternalProviderParams, ProviderControllers, ProviderSchema, ProviderSetupMeta } from '../Provider'
import { createController } from '../ProviderService'
import TextError, { UndeliverableTextError, UnsubscribeTextError } from './TextError'
import { InboundTextMessage, TextMessage, TextResponse } from './TextMessage'
import { TextProvider } from './TextProvider'
@ -50,7 +51,6 @@ export default class TwilioTextProvider extends TextProvider {
}
loadSetup(app: App): ProviderSetupMeta[] {
console.log('load setup')
return [{
name: 'Unsubscribe URL',
value: `${app.env.apiBaseUrl}/providers/${encodeHashid(this.id)}/${(this.constructor as any).namespace}/unsubscribe`,
@ -81,7 +81,14 @@ export default class TwilioTextProvider extends TextProvider {
response: responseBody.sid,
}
} else {
throw new Error(`${response.status} - ${responseBody.message}`)
if (responseBody.code === 21610) {
// Unable to send because recipient has unsubscribed
throw new UnsubscribeTextError(this.type, this.phone_number, responseBody.message)
} else if (responseBody.code === 21408) {
// Unable to send because region is not enabled
throw new UndeliverableTextError(this.type, this.phone_number, responseBody.message)
}
throw new TextError(this.type, this.phone_number, responseBody.message)
}
}

View file

@ -119,6 +119,7 @@ router.patch('/', projectRoleMiddleware('editor'), async ctx => {
})
const deleteUsersRequest: JSONSchemaType<string[]> = {
$id: 'deleteUsers',
type: 'array',
items: {
type: 'string',

View file

@ -59,7 +59,7 @@ export default function ProjectSidebar({ children, links }: PropsWithChildren<Si
value={project}
onChange={project => {
if (project.id === 0) {
navigate('/settings/projects')
navigate('/organization/projects')
} else {
navigate(`/projects/${project.id}`)
}