Skip to content

Commit 691f481

Browse files
fix: socket mode should warn when ack is invoked multiple times (#2519)
Co-authored-by: Eden Zimbelman <eden.zimbelman@salesforce.com>
1 parent eebf16f commit 691f481

7 files changed

Lines changed: 171 additions & 38 deletions

File tree

src/receivers/HTTPResponseAck.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { IncomingMessage, ServerResponse } from 'node:http';
22
import type { Logger } from '@slack/logger';
33
import { ReceiverMultipleAckError } from '../errors';
4-
import type { AckFn } from '../types';
4+
import type { AckFn, ResponseAck } from '../types';
55
import * as httpFunc from './HTTPModuleFunctions';
66

77
export interface AckArgs {
@@ -13,10 +13,11 @@ export interface AckArgs {
1313
httpResponse: ServerResponse;
1414
}
1515

16+
// TODO: (semver:major) change this to "HTTPResponseBody"
1617
// biome-ignore lint/suspicious/noExplicitAny: response bodies can be anything
1718
export type HTTResponseBody = any;
1819

19-
export class HTTPResponseAck {
20+
export class HTTPResponseAck implements ResponseAck {
2021
private logger: Logger;
2122

2223
private isAcknowledged: boolean;

src/receivers/SocketModeReceiver.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
type SocketModeReceiverProcessEventErrorHandlerArgs,
2222
defaultProcessEventErrorHandler,
2323
} from './SocketModeFunctions';
24+
import { SocketModeResponseAck } from './SocketModeResponseAck';
2425
import { type ReceiverRoutes, buildReceiverRoutes } from './custom-routes';
2526
import { verifyRedirectOpts } from './verify-redirect-opts';
2627

@@ -224,10 +225,11 @@ export default class SocketModeReceiver implements Receiver {
224225
}
225226

226227
this.client.on('slack_event', async (args) => {
227-
const { ack, body, retry_num, retry_reason } = args;
228+
const { body, retry_num, retry_reason } = args;
229+
const ack = new SocketModeResponseAck({ logger: this.logger, socketModeClientAck: args.ack });
228230
const event: ReceiverEvent = {
229231
body,
230-
ack,
232+
ack: ack.bind(),
231233
retryNum: retry_num,
232234
retryReason: retry_reason,
233235
customProperties: customPropertiesExtractor(args),
@@ -241,7 +243,7 @@ export default class SocketModeReceiver implements Receiver {
241243
event,
242244
});
243245
if (shouldBeAcked) {
244-
await ack();
246+
await event.ack();
245247
}
246248
}
247249
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import type { Logger } from '@slack/logger';
2+
import type { AckFn, ResponseAck } from '../types';
3+
4+
// biome-ignore lint/suspicious/noExplicitAny: response bodies can be anything
5+
export type SocketModeResponseBody = any;
6+
type SocketModeClientAck = (response?: Record<string, unknown>) => Promise<void>;
7+
8+
export interface AckArgs {
9+
logger: Logger;
10+
socketModeClientAck: SocketModeClientAck;
11+
}
12+
13+
export class SocketModeResponseAck implements ResponseAck {
14+
private logger: Logger;
15+
16+
private isAcknowledged: boolean;
17+
18+
// TODO: the SocketModeClient should expose its send method and it should be used to acknowledge rather then an arbitrary ack() function
19+
private socketModeClientAck: SocketModeClientAck;
20+
21+
public constructor(args: AckArgs) {
22+
this.logger = args.logger;
23+
this.isAcknowledged = false;
24+
this.socketModeClientAck = args.socketModeClientAck;
25+
// TODO: a Timeout should be used to acknowledge the request after 3 seconds and mimic the HTTPReceiver behavior
26+
}
27+
28+
public bind(): AckFn<SocketModeResponseBody> {
29+
return async (responseBody) => {
30+
this.logger.debug(`ack() call begins (body: ${JSON.stringify(responseBody)})`);
31+
if (this.isAcknowledged) {
32+
// TODO: (semver:major) this should throw a ReceiverMultipleAckError error instead of printing a debug message
33+
this.logger.warn(
34+
'ack() has already been called. Additional calls will be ignored and may lead to errors in other receivers.',
35+
);
36+
return;
37+
}
38+
this.isAcknowledged = true;
39+
await this.socketModeClientAck(responseBody);
40+
this.logger.debug(`ack() response sent (body: ${JSON.stringify(responseBody)})`);
41+
};
42+
}
43+
}

src/types/receiver.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,9 @@ export interface Receiver {
3030
// biome-ignore lint/suspicious/noExplicitAny: different receivers may have different types of arguments
3131
stop(...args: any[]): Promise<unknown>;
3232
}
33+
34+
export interface ResponseAck {
35+
// Returns the function to acknowledge incoming requests
36+
// biome-ignore lint/suspicious/noExplicitAny: TODO: Make the argument type more specific
37+
bind(): AckFn<any>;
38+
}

test/unit/receivers/HTTPResponseAck.spec.ts

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,27 @@
11
import { IncomingMessage, ServerResponse } from 'node:http';
22
import { assert } from 'chai';
33
import sinon from 'sinon';
4+
import { expectType } from 'tsd';
45
import { ReceiverMultipleAckError } from '../../../src/errors';
56
import * as HTTPModuleFunctions from '../../../src/receivers/HTTPModuleFunctions';
67
import { HTTPResponseAck } from '../../../src/receivers/HTTPResponseAck';
8+
import type { ResponseAck } from '../../../src/types';
79
import { createFakeLogger } from '../helpers';
810

911
describe('HTTPResponseAck', async () => {
10-
it('should work', async () => {
12+
it('should implement ResponseAck and work', async () => {
1113
const httpRequest = sinon.createStubInstance(IncomingMessage) as IncomingMessage;
1214
const httpResponse: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse;
13-
const ack = new HTTPResponseAck({
15+
const responseAck = new HTTPResponseAck({
1416
logger: createFakeLogger(),
1517
processBeforeResponse: false,
1618
httpRequest,
1719
httpResponse,
1820
});
19-
assert.isDefined(ack);
20-
assert.isDefined(ack.bind());
21-
ack.ack(); // no exception
21+
assert.isDefined(responseAck);
22+
assert.isDefined(responseAck.bind());
23+
expectType<ResponseAck>(responseAck);
24+
responseAck.ack(); // no exception
2225
});
2326
it('should trigger unhandledRequestHandler if unacknowledged', (done) => {
2427
const httpRequest = sinon.createStubInstance(IncomingMessage) as IncomingMessage;
@@ -58,14 +61,14 @@ describe('HTTPResponseAck', async () => {
5861
it('should throw an error if a bound Ack invocation was already acknowledged', async () => {
5962
const httpRequest = sinon.createStubInstance(IncomingMessage) as IncomingMessage;
6063
const httpResponse: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse;
61-
const ack = new HTTPResponseAck({
64+
const responseAck = new HTTPResponseAck({
6265
logger: createFakeLogger(),
6366
processBeforeResponse: false,
6467
httpRequest,
6568
httpResponse,
6669
});
67-
const bound = ack.bind();
68-
ack.ack();
70+
const bound = responseAck.bind();
71+
responseAck.ack();
6972
try {
7073
await bound();
7174
assert.fail('No exception raised');
@@ -76,31 +79,31 @@ describe('HTTPResponseAck', async () => {
7679
it('should store response body if processBeforeResponse=true', async () => {
7780
const httpRequest = sinon.createStubInstance(IncomingMessage) as IncomingMessage;
7881
const httpResponse: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse;
79-
const ack = new HTTPResponseAck({
82+
const responseAck = new HTTPResponseAck({
8083
logger: createFakeLogger(),
8184
processBeforeResponse: true,
8285
httpRequest,
8386
httpResponse,
8487
});
85-
const bound = ack.bind();
88+
const bound = responseAck.bind();
8689
const body = { some: 'thing' };
8790
await bound(body);
88-
assert.equal(ack.storedResponse, body, 'Body passed to bound handler not stored in Ack instance.');
91+
assert.equal(responseAck.storedResponse, body, 'Body passed to bound handler not stored in Ack instance.');
8992
});
9093
it('should store an empty string if response body is falsy and processBeforeResponse=true', async () => {
9194
const httpRequest = sinon.createStubInstance(IncomingMessage) as IncomingMessage;
9295
const httpResponse: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse;
93-
const ack = new HTTPResponseAck({
96+
const responseAck = new HTTPResponseAck({
9497
logger: createFakeLogger(),
9598
processBeforeResponse: true,
9699
httpRequest,
97100
httpResponse,
98101
});
99-
const bound = ack.bind();
102+
const bound = responseAck.bind();
100103
const body = false;
101104
await bound(body);
102105
assert.equal(
103-
ack.storedResponse,
106+
responseAck.storedResponse,
104107
'',
105108
'Falsy body passed to bound handler not stored as empty string in Ack instance.',
106109
);
@@ -109,13 +112,13 @@ describe('HTTPResponseAck', async () => {
109112
const stub = sinon.stub(HTTPModuleFunctions, 'buildContentResponse');
110113
const httpRequest = sinon.createStubInstance(IncomingMessage) as IncomingMessage;
111114
const httpResponse: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse;
112-
const ack = new HTTPResponseAck({
115+
const responseAck = new HTTPResponseAck({
113116
logger: createFakeLogger(),
114117
processBeforeResponse: false,
115118
httpRequest,
116119
httpResponse,
117120
});
118-
const bound = ack.bind();
121+
const bound = responseAck.bind();
119122
const body = { some: 'thing' };
120123
await bound(body);
121124
assert(

test/unit/receivers/SocketModeReceiver.spec.ts

Lines changed: 40 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -657,9 +657,8 @@ describe('SocketModeReceiver', () => {
657657
});
658658

659659
describe('event', () => {
660-
it('acknowledges processed events', async () => {
661-
const processStub = sinon.stub<[ReceiverEvent]>().resolves();
662-
processStub.callsFake(async (event: ReceiverEvent) => {
660+
it('should allow events processed to be acknowledged', async () => {
661+
const processStub = sinon.stub<[ReceiverEvent]>().callsFake(async (event: ReceiverEvent) => {
663662
await event.ack();
664663
});
665664
const app = sinon.createStubInstance(App, { processEvent: processStub }) as unknown as App;
@@ -668,17 +667,17 @@ describe('SocketModeReceiver', () => {
668667
receiver.init(app);
669668

670669
// Stub ack with an awaited promise for tests
671-
let resolve: () => void;
672-
const called = new Promise<void>((r) => {
673-
resolve = r;
670+
let notifyAckCalled: () => void;
671+
const ackCalled = new Promise<void>((resolve) => {
672+
notifyAckCalled = resolve;
674673
});
675674
const ack = sinon.stub().callsFake(async () => {
676-
resolve();
675+
notifyAckCalled();
677676
});
678677

679678
receiver.client.emit('slack_event', { ack });
680-
await called;
681-
assert.isTrue(ack.called);
679+
await ackCalled;
680+
sinon.assert.calledOnce(ack);
682681
});
683682

684683
it('acknowledges events that throw AuthorizationError', async () => {
@@ -689,17 +688,17 @@ describe('SocketModeReceiver', () => {
689688
receiver.init(app);
690689

691690
// Stub ack with an awaited promise for tests
692-
let resolve: () => void;
693-
const called = new Promise<void>((r) => {
694-
resolve = r;
691+
let notifyAckCalled: () => void;
692+
const ackCalled = new Promise<void>((resolve) => {
693+
notifyAckCalled = resolve;
695694
});
696695
const ack = sinon.stub().callsFake(async () => {
697-
resolve();
696+
notifyAckCalled();
698697
});
699698

700699
receiver.client.emit('slack_event', { ack });
701-
await called;
702-
assert.isTrue(ack.called);
700+
await ackCalled;
701+
sinon.assert.calledOnce(ack);
703702
});
704703

705704
it('does not acknowledge events that throw unknown errors', async () => {
@@ -714,13 +713,37 @@ describe('SocketModeReceiver', () => {
714713
receiver.init(app);
715714

716715
const slackRequestArgs = { body: {}, ack: sinon.fake() };
717-
718716
receiver.client.emit('slack_event', slackRequestArgs);
717+
719718
while (!defaultProcessEventErrorHandlerSpy.called) {
720719
await delay(50);
721720
}
722-
721+
sinon.assert.calledOnce(defaultProcessEventErrorHandlerSpy);
723722
sinon.assert.notCalled(slackRequestArgs.ack);
724723
});
724+
725+
it('does not re-acknowledge events that handle acknowledge and then throw unknown errors', async () => {
726+
const processStub = sinon.stub<[ReceiverEvent]>().callsFake(async (event: ReceiverEvent) => {
727+
await event.ack();
728+
throw new Error('internal error');
729+
});
730+
const defaultProcessEventErrorHandlerSpy = sinon.spy(defaultProcessEventErrorHandler);
731+
const app = sinon.createStubInstance(App, { processEvent: processStub }) as unknown as App;
732+
const SocketModeReceiver = await importSocketModeReceiver(overrides);
733+
const receiver = new SocketModeReceiver({
734+
appToken: 'xapp-example',
735+
processEventErrorHandler: defaultProcessEventErrorHandlerSpy,
736+
});
737+
receiver.init(app);
738+
739+
const slackRequestArgs = { body: {}, ack: sinon.fake() };
740+
receiver.client.emit('slack_event', slackRequestArgs);
741+
742+
while (!defaultProcessEventErrorHandlerSpy.called) {
743+
await delay(50);
744+
}
745+
sinon.assert.calledOnce(defaultProcessEventErrorHandlerSpy);
746+
sinon.assert.calledOnce(slackRequestArgs.ack);
747+
});
725748
});
726749
});
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { assert } from 'chai';
2+
import sinon from 'sinon';
3+
import { expectType } from 'tsd';
4+
import { SocketModeResponseAck } from '../../../src/receivers/SocketModeResponseAck';
5+
import type { ResponseAck } from '../../../src/types';
6+
import { createFakeLogger } from '../helpers';
7+
8+
describe('SocketModeResponseAck', async () => {
9+
const fakeSocketModeClientAck = sinon.fake();
10+
const fakeLogger = createFakeLogger();
11+
12+
beforeEach(() => {
13+
sinon.reset();
14+
});
15+
16+
it('should implement ResponseAck', async () => {
17+
const responseAck = new SocketModeResponseAck({
18+
logger: fakeLogger,
19+
socketModeClientAck: fakeSocketModeClientAck,
20+
});
21+
assert.isDefined(responseAck);
22+
assert.isDefined(responseAck.bind());
23+
expectType<ResponseAck>(responseAck);
24+
});
25+
26+
describe('bind', async () => {
27+
it('should create bound Ack that invoke the response to the request', async () => {
28+
const responseAck = new SocketModeResponseAck({
29+
logger: fakeLogger,
30+
socketModeClientAck: fakeSocketModeClientAck,
31+
});
32+
const ack = responseAck.bind();
33+
await ack(); // no exception
34+
sinon.assert.calledOnce(fakeSocketModeClientAck);
35+
});
36+
37+
it('should log an error message when there are more then 1 bound Ack invocation', async () => {
38+
const responseAck = new SocketModeResponseAck({
39+
logger: fakeLogger,
40+
socketModeClientAck: fakeSocketModeClientAck,
41+
});
42+
const ack = responseAck.bind();
43+
await ack();
44+
sinon.assert.neverCalledWith(
45+
fakeLogger.warn,
46+
'ack() has already been called. Additional calls will be ignored and may lead to errors in other receivers.',
47+
);
48+
await ack();
49+
sinon.assert.calledWith(
50+
fakeLogger.warn,
51+
'ack() has already been called. Additional calls will be ignored and may lead to errors in other receivers.',
52+
);
53+
});
54+
});
55+
});

0 commit comments

Comments
 (0)