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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,21 @@ completes in 5–17 s at 26–62 neurons per feedback, versus ~1 s and ~4.7 neur
for the old 8B model. Queue consumers use `max_batch_size: 5`, so a batch stays
well inside the invocation limit.

### フィードバック受付時の認証確認

`POST /postFeedback` では、キューへの投函前に `OCTOKIT_PAT` で
`TrainLCD/Issues` のリポジトリ情報を取得します。PAT 未設定・失効、GitHub の
HTTP エラー、5 秒のタイムアウト、Issues 無効時には、HTTP 503 と
`{ "error": { "status": "unavailable", "message": "Feedback service is temporarily unavailable. Please try again later." } }`
を返します。失敗した投稿はキューへ投函しません。

アプリの `useFeedback` は HTTP エラーを送信失敗として扱い、フィードバック画面で
エラーダイアログを表示します。成功応答はキューへの受付完了を示します。
受付後の PAT 失効や Issue 作成時の検証エラーまで同期的に保証するものではありません。
GitHub の Issue 作成失敗は、キューのログに HTTP ステータス、リクエストID、
検証エラーの項目を残します。レート制限時は `Retry-After` と
`x-ratelimit-reset` に従って再試行を遅らせます。

### Dead letter queue

`processFeedbackMessage()` rethrows on failure so the consumer can `retry()`,
Expand Down
81 changes: 37 additions & 44 deletions src/consumers/feedbackTriage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* Discord 通知を行う。AI 呼び出し以外のロジックは旧 Cloud Functions から流用。
*/
import dayjs from 'dayjs';
import { githubIssueError } from '../lib/githubIssueError';
import type {
AICategory,
AIComponent,
Expand Down Expand Up @@ -691,6 +692,7 @@ function githubPost(env: Env, path: string, body: unknown): Promise<Response> {
method: 'post',
headers: {
Accept: 'application/vnd.github+json',
'Content-Type': 'application/json',
Authorization: `Bearer ${env.OCTOKIT_PAT ?? ''}`,
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'trainlcd-worker',
Expand Down Expand Up @@ -1446,10 +1448,9 @@ export const processFeedbackMessage = async (
let publicIssueUrl = marker?.publicIssueUrl ?? null;

if (!marker) {
try {
const res = await githubPost(env, `${INTERNAL_REPO}/issues`, {
title: aiReport.title ?? '要約未取得',
body: `
const res = await githubPost(env, `${INTERNAL_REPO}/issues`, {
title: aiReport.title ?? '要約未取得',
body: `
![Image](${imageUrl})


Expand Down Expand Up @@ -1495,49 +1496,41 @@ ${sentryEventId}
## レポーターUID
${reporterUid}
`.trim(),
assignees: ['TinyKitten'],
milestone: null,
labels: [
reportType === 'feedback' &&
!aiReport.isSpam &&
GITHUB_LABELS.FEEDBACK_TYPE,
reportType === 'crash' && GITHUB_LABELS.CRASH_TYPE,
appEdition === 'production' && GITHUB_LABELS.PRODUCTION_APP,
appEdition === 'canary' && GITHUB_LABELS.CANARY_APP,
appClip && GITHUB_LABELS.PLATFORM_APPCLIP,
aiReport.isSpam && GITHUB_LABELS.SPAM_TYPE,
(triageFailed || needsSpamReview) && GITHUB_LABELS.UNKNOWN_TYPE,
osNameLabel,
autoModeLabel,
categoryLabel,
triageLabel,
].filter(Boolean),
});

if (res.status !== 201) {
console.error(await res.text().catch(() => ''));
throw new Error(`GitHub API failed with status ${res.status}`);
}
assignees: ['TinyKitten'],
milestone: null,
labels: [
reportType === 'feedback' &&
!aiReport.isSpam &&
GITHUB_LABELS.FEEDBACK_TYPE,
reportType === 'crash' && GITHUB_LABELS.CRASH_TYPE,
appEdition === 'production' && GITHUB_LABELS.PRODUCTION_APP,
appEdition === 'canary' && GITHUB_LABELS.CANARY_APP,
appClip && GITHUB_LABELS.PLATFORM_APPCLIP,
aiReport.isSpam && GITHUB_LABELS.SPAM_TYPE,
(triageFailed || needsSpamReview) && GITHUB_LABELS.UNKNOWN_TYPE,
osNameLabel,
autoModeLabel,
categoryLabel,
triageLabel,
].filter(Boolean),
});

// ここから先は Issue 作成済み。throw して再試行させると重複起票になるため、
// レスポンスの解析に失敗しても続行し、分かった範囲をマーカーに残す。
const created = (await res.json().catch((err: unknown) => {
console.error('feedbackTriage: 起票レスポンスの解析に失敗', {
reportId: id,
error: err instanceof Error ? err.message : String(err),
});
return null;
})) as { html_url?: string; number?: number } | null;
issueNumber = typeof created?.number === 'number' ? created.number : null;
issueUrl =
typeof created?.html_url === 'string' ? created.html_url : null;
} catch (err) {
// Issue 作成前の失敗。握りつぶすと queue ハンドラが ack してメッセージを失うため、
// 再送出して再試行させる(この時点では Issue は作られていないので重複しない)。
console.error(err);
throw err;
if (res.status !== 201) {
throw await githubIssueError(res);
}

// ここから先は Issue 作成済み。throw して再試行させると重複起票になるため、
// レスポンスの解析に失敗しても続行し、分かった範囲をマーカーに残す。
const created = (await res.json().catch((err: unknown) => {
console.error('feedbackTriage: 起票レスポンスの解析に失敗', {
reportId: id,
error: err instanceof Error ? err.message : String(err),
});
return null;
})) as { html_url?: string; number?: number } | null;
issueNumber = typeof created?.number === 'number' ? created.number : null;
issueUrl = typeof created?.html_url === 'string' ? created.html_url : null;

// 原因コンポーネントが特定できている場合のみ、該当の公開リポジトリにも起票する。
// 公開側に載せるのは管理 Issue 番号とチケットIDだけで、フィードバックの内容は含めない。
// 起票後は管理 Issue 側にもコメントでリンクを残し、双方向に追えるようにする。
Expand Down
55 changes: 55 additions & 0 deletions src/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import {
FEEDBACK_RETRY_DELAY_SECONDS,
processFeedbackMessage,
} from './consumers/feedbackTriage';
import worker from './index';
import { GitHubIssueError } from './lib/githubIssueError';
import type { Env, FeedbackQueueMessage } from './types';

jest.mock('./consumers/feedbackTriage', () => ({
FEEDBACK_RETRY_DELAY_SECONDS: 90,
processFeedbackMessage: jest.fn(),
}));

it('GitHub のエラー詳細をログに残し、解除時刻まで再試行を遅らせる', async () => {
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
const ack = jest.fn();
const retry = jest.fn();
const batch = {
queue: 'feedback-triage',
messages: [
{ id: 'msg-1', body: { report: { id: 'report-1' } }, ack, retry },
],
} as unknown as MessageBatch<FeedbackQueueMessage>;
try {
jest
.mocked(processFeedbackMessage)
.mockRejectedValueOnce(
new GitHubIssueError(403, 'request-1', 3600, { errors: [] })
);
await worker.queue?.(batch, {} as Env, {} as ExecutionContext);
expect(ack).not.toHaveBeenCalled();
expect(retry).toHaveBeenCalledWith({ delaySeconds: 3600 });
expect(spy).toHaveBeenCalledWith(
'Queue message failed (feedback-triage):',
expect.objectContaining({
status: 403,
errorMessage: 'GitHub API failed with status 403',
reportId: 'report-1',
requestId: 'request-1',
})
);
jest
.mocked(processFeedbackMessage)
.mockRejectedValueOnce(new Error('network error'));
await worker.queue?.(batch, {} as Env, {} as ExecutionContext);
expect(retry).toHaveBeenLastCalledWith({
delaySeconds: FEEDBACK_RETRY_DELAY_SECONDS,
});
jest.mocked(processFeedbackMessage).mockResolvedValueOnce(undefined);
await worker.queue?.(batch, {} as Env, {} as ExecutionContext);
expect(ack).toHaveBeenCalledTimes(1);
} finally {
spy.mockRestore();
}
});
18 changes: 16 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
processFeedbackMessage,
} from './consumers/feedbackTriage';
import { withCallable } from './lib/callable';
import { GitHubIssueError } from './lib/githubIssueError';
import { handleAuthToken } from './routes/auth';
import { handleMaintenanceConfig, handleRemoteConfig } from './routes/config';
import { handleFeedback } from './routes/feedback';
Expand Down Expand Up @@ -80,11 +81,24 @@ const worker: ExportedHandler<Env, FeedbackQueueMessage> = {
await processFeedbackMessage(message.body, env);
message.ack();
} catch (e) {
console.error(`Queue message failed (${batch.queue}):`, e);
const delaySeconds = Math.max(
FEEDBACK_RETRY_DELAY_SECONDS,
e instanceof GitHubIssueError ? (e.retryDelaySeconds ?? 0) : 0
);
console.error(`Queue message failed (${batch.queue}):`, {
messageId: message.id,
reportId: message.body?.report?.id,
errorName: e instanceof Error ? e.name : 'UnknownError',
errorMessage: e instanceof Error ? e.message : String(e),
...(e instanceof GitHubIssueError
? { status: e.status, requestId: e.requestId, details: e.details }
: {}),
delaySeconds,
});
// 遅延なしで再試行すると、processFeedbackMessage が起票直後に書いた
// 冪等化マーカーを KV のネガティブキャッシュ越しに読めず、Issue を
// 作り直してしまう。キャッシュが切れてから再試行させる。
message.retry({ delaySeconds: FEEDBACK_RETRY_DELAY_SECONDS });
message.retry({ delaySeconds });
}
}
},
Expand Down
55 changes: 55 additions & 0 deletions src/lib/feedbackAvailability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { Env } from '../types';
import { CallableError } from './callable';

/** キュー受付前に、起票先へ認証できることを確認する。成功はキャッシュしない。 */
export async function ensureFeedbackAvailable(env: Env): Promise<void> {
const unavailable = () =>
new CallableError(
'unavailable',
'Feedback service is temporarily unavailable. Please try again later.'
);
if (!env.OCTOKIT_PAT?.trim()) {
console.error('Feedback intake unavailable', {
reason: 'OCTOKIT_PAT is not set',
});
throw unavailable();
}
let response: Response;
try {
// Issue 本文は取得せず、リポジトリへの認証・アクセスを検証する。
response = await fetch('https://api.github.com/repos/TrainLCD/Issues', {
headers: {
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${env.OCTOKIT_PAT}`,
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'trainlcd-worker',
},
signal: AbortSignal.timeout(5000),
});
} catch {
console.error('Feedback intake unavailable', {
reason: 'GitHub request failed or timed out',
});
throw unavailable();
}
if (!response.ok) {
console.error('Feedback intake unavailable', {
status: response.status,
requestId: response.headers.get('x-github-request-id'),
});
await response.body?.cancel().catch(() => {});
throw unavailable();
}
const raw: unknown = await response.json().catch(() => null);
if (
!raw ||
typeof raw !== 'object' ||
!('has_issues' in raw) ||
raw.has_issues !== true
) {
console.error('Feedback intake unavailable', {
reason: 'GitHub issue repository is unavailable',
});
throw unavailable();
}
}
Loading
Loading