From a33b5d527832ea8ad1c88904d02fbaf1819509e2 Mon Sep 17 00:00:00 2001 From: Yousei Takahashi Date: Tue, 9 Jun 2026 11:32:46 +0900 Subject: [PATCH 1/5] =?UTF-8?q?fix:=20Slack=E3=83=AA=E3=83=88=E3=83=A9?= =?UTF-8?q?=E3=82=A4=E3=83=AA=E3=82=AF=E3=82=A8=E3=82=B9=E3=83=88=E3=82=92?= =?UTF-8?q?=E3=82=B9=E3=82=AD=E3=83=83=E3=83=97=E3=81=97=E3=81=A6=E9=87=8D?= =?UTF-8?q?=E8=A4=87=E6=8A=95=E7=A8=BF=E3=82=92=E9=98=B2=E6=AD=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slackが3秒以内にレスポンスを受け取れない場合にリトライを送信し、 別のLambdaインスタンスが同じイベントを処理することで重複投稿が発生していた。 x-slack-retry-numヘッダーの存在をチェックし、リトライリクエストは即座に200を返す。 Co-Authored-By: Claude Opus 4.6 --- src/__tests__/handler.test.ts | 24 ++++++++++++++++++++++++ src/handler.ts | 13 ++++++++++++- src/lib/slackRetry.ts | 7 +++++++ 3 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/handler.test.ts create mode 100644 src/lib/slackRetry.ts diff --git a/src/__tests__/handler.test.ts b/src/__tests__/handler.test.ts new file mode 100644 index 0000000..5522bec --- /dev/null +++ b/src/__tests__/handler.test.ts @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { isSlackRetry } from '../lib/slackRetry.ts'; + +describe('isSlackRetry', () => { + it('x-slack-retry-numヘッダーがある場合はtrueを返す', () => { + const headers = { + 'x-slack-retry-num': '1', + 'x-slack-retry-reason': 'http_timeout', + }; + assert.equal(isSlackRetry(headers), true); + }); + + it('x-slack-retry-numヘッダーがない場合はfalseを返す', () => { + const headers = { + 'content-type': 'application/json', + }; + assert.equal(isSlackRetry(headers), false); + }); + + it('headersがundefinedの場合はfalseを返す', () => { + assert.equal(isSlackRetry(undefined), false); + }); +}); diff --git a/src/handler.ts b/src/handler.ts index a893b28..cf0b6c0 100644 --- a/src/handler.ts +++ b/src/handler.ts @@ -1,5 +1,7 @@ import { createSlackApp } from './app.ts'; import { loadEnv } from './env.ts'; +import type { SlackHeaders } from './lib/slackRetry.ts'; +import { isSlackRetry } from './lib/slackRetry.ts'; const env = loadEnv(); const { receiver } = createSlackApp(env); @@ -12,8 +14,17 @@ export const handler = async ( event: Record, context: unknown, ) => { + // biome-ignore lint/suspicious/noExplicitAny: AwsEventの型がexportされていないため + const headers = (event as any).headers as SlackHeaders; + if (isSlackRetry(headers)) { + console.log('skipped: slack retry', { + retryNum: headers?.['x-slack-retry-num'], + retryReason: headers?.['x-slack-retry-reason'] ?? 'unknown', + }); + return { statusCode: 200, body: 'ok (retry skipped)' }; + } + const boltHandler = await receiver.start(); - // boltHandlerは内部的にcallbackを使用しないが、型定義上3引数が必須 // biome-ignore lint/suspicious/noExplicitAny: AwsEventの型がexportされていないため return boltHandler(event as any, context, () => {}); }; diff --git a/src/lib/slackRetry.ts b/src/lib/slackRetry.ts new file mode 100644 index 0000000..3c26215 --- /dev/null +++ b/src/lib/slackRetry.ts @@ -0,0 +1,7 @@ +import type { APIGatewayProxyEventV2 } from 'aws-lambda'; + +export type SlackHeaders = APIGatewayProxyEventV2['headers'] | undefined; + +export function isSlackRetry(headers: SlackHeaders): boolean { + return !!headers?.['x-slack-retry-num']; +} From 9ebb5b08451f2cc4864869bcd871f9f9c2f60aef Mon Sep 17 00:00:00 2001 From: Yousei Takahashi Date: Tue, 9 Jun 2026 11:35:34 +0900 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20handler=E3=81=AEevent=E5=9E=8B?= =?UTF-8?q?=E3=82=92APIGatewayProxyEventV2=E3=81=AB=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E3=81=97=E3=81=A6any=E3=82=AD=E3=83=A3=E3=82=B9=E3=83=88?= =?UTF-8?q?=E3=82=92=E9=99=A4=E5=8E=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- src/__tests__/handler.test.ts | 4 ---- src/handler.ts | 12 +++++------- src/lib/slackRetry.ts | 8 ++++---- 3 files changed, 9 insertions(+), 15 deletions(-) diff --git a/src/__tests__/handler.test.ts b/src/__tests__/handler.test.ts index 5522bec..bcaf77e 100644 --- a/src/__tests__/handler.test.ts +++ b/src/__tests__/handler.test.ts @@ -17,8 +17,4 @@ describe('isSlackRetry', () => { }; assert.equal(isSlackRetry(headers), false); }); - - it('headersがundefinedの場合はfalseを返す', () => { - assert.equal(isSlackRetry(undefined), false); - }); }); diff --git a/src/handler.ts b/src/handler.ts index cf0b6c0..6815d5c 100644 --- a/src/handler.ts +++ b/src/handler.ts @@ -1,6 +1,6 @@ +import type { APIGatewayProxyEventV2 } from 'aws-lambda'; import { createSlackApp } from './app.ts'; import { loadEnv } from './env.ts'; -import type { SlackHeaders } from './lib/slackRetry.ts'; import { isSlackRetry } from './lib/slackRetry.ts'; const env = loadEnv(); @@ -11,15 +11,13 @@ const { receiver } = createSlackApp(env); // AwsLambdaReceiverのハンドラーを2引数のasync関数でラップする // ref: https://github.com/slackapi/bolt-js/issues/2761 export const handler = async ( - event: Record, + event: APIGatewayProxyEventV2, context: unknown, ) => { - // biome-ignore lint/suspicious/noExplicitAny: AwsEventの型がexportされていないため - const headers = (event as any).headers as SlackHeaders; - if (isSlackRetry(headers)) { + if (isSlackRetry(event.headers)) { console.log('skipped: slack retry', { - retryNum: headers?.['x-slack-retry-num'], - retryReason: headers?.['x-slack-retry-reason'] ?? 'unknown', + retryNum: event.headers['x-slack-retry-num'], + retryReason: event.headers['x-slack-retry-reason'] ?? 'unknown', }); return { statusCode: 200, body: 'ok (retry skipped)' }; } diff --git a/src/lib/slackRetry.ts b/src/lib/slackRetry.ts index 3c26215..c005bc6 100644 --- a/src/lib/slackRetry.ts +++ b/src/lib/slackRetry.ts @@ -1,7 +1,7 @@ import type { APIGatewayProxyEventV2 } from 'aws-lambda'; -export type SlackHeaders = APIGatewayProxyEventV2['headers'] | undefined; - -export function isSlackRetry(headers: SlackHeaders): boolean { - return !!headers?.['x-slack-retry-num']; +export function isSlackRetry( + headers: APIGatewayProxyEventV2['headers'], +): boolean { + return !!headers['x-slack-retry-num']; } From 227d279cb9ad1366ae1bb85989fb7d0786551c57 Mon Sep 17 00:00:00 2001 From: Yousei Takahashi Date: Tue, 9 Jun 2026 11:39:57 +0900 Subject: [PATCH 3/5] =?UTF-8?q?refactor:=20handler.ts=E3=81=8B=E3=82=89as?= =?UTF-8?q?=20any=E3=82=AD=E3=83=A3=E3=82=B9=E3=83=88=E3=82=92=E9=99=A4?= =?UTF-8?q?=E5=8E=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit APIGatewayProxyEventV2はBoltのAwsEventに構造的に代入可能なためキャスト不要。 Co-Authored-By: Claude Opus 4.6 --- src/handler.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/handler.ts b/src/handler.ts index 6815d5c..a969a63 100644 --- a/src/handler.ts +++ b/src/handler.ts @@ -23,6 +23,5 @@ export const handler = async ( } const boltHandler = await receiver.start(); - // biome-ignore lint/suspicious/noExplicitAny: AwsEventの型がexportされていないため - return boltHandler(event as any, context, () => {}); + return boltHandler(event, context, () => {}); }; From d8d6aa6aa363ef2f70ea8c240db2e737f6a370a1 Mon Sep 17 00:00:00 2001 From: Yousei Takahashi Date: Tue, 9 Jun 2026 11:57:11 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20=E3=83=AA=E3=83=88=E3=83=A9=E3=82=A4?= =?UTF-8?q?=E3=82=B9=E3=82=AD=E3=83=83=E3=83=97=E3=82=92http=5Ftimeout?= =?UTF-8?q?=E3=81=AE=E3=81=BF=E3=81=AB=E9=99=90=E5=AE=9A=E3=81=97=E3=81=A6?= =?UTF-8?q?=E5=8F=96=E3=82=8A=E3=81=93=E3=81=BC=E3=81=97=E3=82=92=E9=98=B2?= =?UTF-8?q?=E6=AD=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 全リトライをスキップするとLambdaエラー時のリトライまで捨ててしまうため、 重複投稿の原因であるhttp_timeoutの場合のみスキップするよう変更。 Co-Authored-By: Claude Opus 4.6 --- src/handler.ts | 7 +++---- src/lib/slackRetry.ts | 7 +++++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/handler.ts b/src/handler.ts index a969a63..f8cb262 100644 --- a/src/handler.ts +++ b/src/handler.ts @@ -1,7 +1,7 @@ import type { APIGatewayProxyEventV2 } from 'aws-lambda'; import { createSlackApp } from './app.ts'; import { loadEnv } from './env.ts'; -import { isSlackRetry } from './lib/slackRetry.ts'; +import { isSlackRetryDueToTimeout } from './lib/slackRetry.ts'; const env = loadEnv(); const { receiver } = createSlackApp(env); @@ -14,10 +14,9 @@ export const handler = async ( event: APIGatewayProxyEventV2, context: unknown, ) => { - if (isSlackRetry(event.headers)) { - console.log('skipped: slack retry', { + if (isSlackRetryDueToTimeout(event.headers)) { + console.log('skipped: slack retry (http_timeout)', { retryNum: event.headers['x-slack-retry-num'], - retryReason: event.headers['x-slack-retry-reason'] ?? 'unknown', }); return { statusCode: 200, body: 'ok (retry skipped)' }; } diff --git a/src/lib/slackRetry.ts b/src/lib/slackRetry.ts index c005bc6..d2a6308 100644 --- a/src/lib/slackRetry.ts +++ b/src/lib/slackRetry.ts @@ -1,7 +1,10 @@ import type { APIGatewayProxyEventV2 } from 'aws-lambda'; -export function isSlackRetry( +export function isSlackRetryDueToTimeout( headers: APIGatewayProxyEventV2['headers'], ): boolean { - return !!headers['x-slack-retry-num']; + return ( + !!headers['x-slack-retry-num'] && + headers['x-slack-retry-reason'] === 'http_timeout' + ); } From e37dbe009996c8cd0f645236ff5cb781295a2574 Mon Sep 17 00:00:00 2001 From: Yousei Takahashi Date: Tue, 9 Jun 2026 11:57:16 +0900 Subject: [PATCH 5/5] =?UTF-8?q?refactor:=20slackRetry=E3=83=86=E3=82=B9?= =?UTF-8?q?=E3=83=88=E3=82=92src/lib/=5F=5Ftests=5F=5F/=E3=81=AB=E7=A7=BB?= =?UTF-8?q?=E5=8B=95=E3=81=97=E3=81=A6=E6=85=A3=E7=BF=92=E3=81=AB=E5=90=88?= =?UTF-8?q?=E3=82=8F=E3=81=9B=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- src/__tests__/handler.test.ts | 20 -------------------- src/lib/__tests__/slackRetry.test.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 20 deletions(-) delete mode 100644 src/__tests__/handler.test.ts create mode 100644 src/lib/__tests__/slackRetry.test.ts diff --git a/src/__tests__/handler.test.ts b/src/__tests__/handler.test.ts deleted file mode 100644 index bcaf77e..0000000 --- a/src/__tests__/handler.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { isSlackRetry } from '../lib/slackRetry.ts'; - -describe('isSlackRetry', () => { - it('x-slack-retry-numヘッダーがある場合はtrueを返す', () => { - const headers = { - 'x-slack-retry-num': '1', - 'x-slack-retry-reason': 'http_timeout', - }; - assert.equal(isSlackRetry(headers), true); - }); - - it('x-slack-retry-numヘッダーがない場合はfalseを返す', () => { - const headers = { - 'content-type': 'application/json', - }; - assert.equal(isSlackRetry(headers), false); - }); -}); diff --git a/src/lib/__tests__/slackRetry.test.ts b/src/lib/__tests__/slackRetry.test.ts new file mode 100644 index 0000000..abe3b3e --- /dev/null +++ b/src/lib/__tests__/slackRetry.test.ts @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { isSlackRetryDueToTimeout } from '../slackRetry.ts'; + +describe('isSlackRetryDueToTimeout', () => { + it('http_timeoutのリトライの場合はtrueを返す', () => { + const headers = { + 'x-slack-retry-num': '1', + 'x-slack-retry-reason': 'http_timeout', + }; + assert.equal(isSlackRetryDueToTimeout(headers), true); + }); + + it('http_timeout以外のリトライの場合はfalseを返す', () => { + const headers = { + 'x-slack-retry-num': '1', + 'x-slack-retry-reason': 'http_error', + }; + assert.equal(isSlackRetryDueToTimeout(headers), false); + }); + + it('x-slack-retry-numヘッダーがない場合はfalseを返す', () => { + const headers = { + 'content-type': 'application/json', + }; + assert.equal(isSlackRetryDueToTimeout(headers), false); + }); +});