/* * **다시 넘길 것인가, 언제인가** — 일꾼이 묻는 것 하나. * * ── 왜 순수한가 ───────────────────────────────────────────────────────────── * 일꾼의 판단이 전부 여기 있다. DB 도 어댑터도 시계도 보지 않는다 — 시각은 받는다. 그래서 「10분 * 뒤에 다시」 같은 규칙을 기다리지 않고 값만 바꿔 가며 잴 수 있다. * * ── 세 갈래는 어댑터가 답한다 ─────────────────────────────────────────────── * 실패가 다 같지 않다. 2026-09-02 에 둘을 겪었다 — 저쪽 인증 정보가 없어 503(고치면 됨)이었고, * `ITEM-1` 은 400(고쳐도 이 조치로는 안 됨)이었다. 답만 보면 구별되지 않았다. * * again 그대로 다시 해 볼 만하다 못 닿았거나 저쪽이 잠깐 흔들렸다 * after-fix 사람이 고친 뒤 그대로 나간다 설정 문제 * never 이 조치로는 영원히 안 된다 지시 내용이 틀렸다 * * 가르는 자리는 **「조치를 다시 낼 필요가 있나」**다(인티그레이션 레인 판단). * * ── `after-fix` 를 횟수로 접지 않는다 ─────────────────────────────────────── * 이것이 이 파일에서 가장 중요한 결정이다. `after-fix` 는 **재시도가 아니라 기다리는 것**이다. * 두드려서 풀릴 일이 아니고, 사람이 설정을 고치는 순간 다음 주기에 나가면 된다. * * 횟수로 접으면 **사람이 고쳐 놓고도 다시 손으로 눌러야** 한다. 2026-09-02 사고가 정확히 그 * 모양이었다 — 저쪽이 고쳐진 뒤 사람이 손으로 다시 넘겨서 나갔다. * * `again` 만 접는다. 끝없이 두드리는 것은 소음이고, 짧게 세 번 해서 안 되면 망이 흔들린 것이 * 아니라 다른 것이다 — 그때는 사람이 봐야 한다. * * ── 멱등이 첫 시도와 재시도를 가른다 ──────────────────────────────────────── * approved → 넘김 **첫 시도**다. 멱등 선언이 없어도 한다 — 한 번뿐이라 위험이 없다 * failed → 넘김 **재시도**다. 두 번 보내도 지시가 하나라고 선언한 커넥터만 한다 * * 선언하지 않은 커넥터의 실패는 목록에 남는다. 사람이 저쪽 상태를 보고 결정해야 한다 — 우리가 * 「아마 괜찮겠지」로 두 번 보내면 현장에 지시가 두 건 선다. */ /** 어댑터가 답하는 세 갈래. 비어 있으면 **말하지 않은 것**이고, `never` 가 아니다. */ export type RetryOutlook = 'again' | 'after-fix' | 'never' export function isRetryOutlook(v: unknown): v is RetryOutlook { return v === 'again' || v === 'after-fix' || v === 'never' } /** * `again` 의 물러섬 — 30초 · 2분 · 10분, 그다음 접는다. * * 첫 값이 일꾼의 주기와 같다(§`RULE_PERIOD_MS`). 새 박자를 만들지 않는다. */ export const AGAIN_BACKOFF_MS = [30_000, 120_000, 600_000] as const /** * `after-fix` 는 **10분마다 계속** 본다 — 접지 않는다(머리말). * * 사람이 설정을 고치는 것을 기다리는 간격이다. 짧게 두면 헛되고, 길게 두면 고쳐 놓고 기다린다. */ export const AFTER_FIX_PERIOD_MS = 600_000 /** 왜 집지 않는가 — 목록이 「고칠 것」과 「우리 쪽을 보지 말 것」을 갈라 보이게 한다. */ export type SkipReason = /** 종착이거나 넘길 수 없는 상태다. */ | 'not-dispatchable' /** 아직 물러섬이 끝나지 않았다 — 다음 기회를 기다린다. */ | 'backing-off' /** 어댑터가 「영원히 안 된다」고 답했다 — 사람이 **새 조치**를 내야 한다. 우리 쪽에 고칠 것이 없다. */ | 'never' /** `again` 을 세 번 다 썼다 — 사람이 봐야 한다. */ | 'attempts-spent' /** 어댑터가 다시 될 일인지 말하지 않았다 — 모르는 것을 재시도로 밀지 않는다. */ | 'outlook-unknown' /** 재시도인데 커넥터가 멱등을 선언하지 않았다 — 두 건이 설 위험을 우리가 지지 않는다. */ | 'not-idempotent' export interface RetryDecision { /** 지금 넘길 것인가. */ take: boolean /** 안 넘기면 왜 — 목록이 그대로 보인다. */ skip?: SkipReason /** 넘길 것이면 이번이 몇 번째 시도인가(첫 시도가 1). */ attempt?: number /** 이번 시도가 실패하면 다음은 언제인가(ISO). 없으면 더 안 본다. */ nextAttemptAt?: string } export interface RetryInput { /** 지금 상태 — `approved` 는 첫 시도, `failed` 는 재시도. */ state: string /** 넘길 수 있는 상태인가 — 상태 기계가 답한다(`nextCommandState(state,'dispatch')`). */ dispatchable: boolean /** 어댑터가 지난 실패에 답한 갈래. 첫 시도에는 없다. */ retry?: string /** 지금까지 몇 번 넘겨 봤나. 없으면 0. */ attempts?: number /** 다음에 볼 시각(ISO). 없으면 지금 봐도 된다. */ nextAttemptAt?: string /** 이 커넥터가 두 번 보내도 지시가 하나인가 — **선언만 참으로 읽는다.** */ idempotent?: boolean /** 지금(ms). */ nowMs: number } const at = (ms: number): string => new Date(ms).toISOString() /** * 이 조치를 지금 넘길 것인가. * * **모르면 넘기지 않는다.** 어댑터가 갈래를 말하지 않았거나 멱등을 선언하지 않았으면 재시도하지 * 않고 목록에 남긴다 — 오늘 커널의 다른 축에서 지킨 것과 같은 규율이다(정격 사용량·품질 계수기· * 조용한 시각: 모르면 단언하지 않는다). */ export function retryDecisionOf(input: RetryInput): RetryDecision { if (!input.dispatchable) return { take: false, skip: 'not-dispatchable' } const attempts = typeof input.attempts === 'number' && input.attempts > 0 ? input.attempts : 0 /* * **첫 시도** — 승인됐는데 한 번도 안 나간 것. 물러섬도 멱등도 묻지 않는다: 한 번뿐이라 두 건이 * 설 수 없고, 어댑터는 아직 아무 답도 하지 않았다. * * 이 갈래가 승인 직후 발신이 놓친 것을 줍는다 — 커밋과 발신 사이에 프로세스가 죽은 경우다. */ if (attempts === 0) { return { take: true, attempt: 1, nextAttemptAt: at(input.nowMs + AGAIN_BACKOFF_MS[0]) } } /* 재시도다. 여기서부터 어댑터의 답과 커넥터의 성질을 묻는다. */ if (!isRetryOutlook(input.retry)) return { take: false, skip: 'outlook-unknown' } if (input.retry === 'never') return { take: false, skip: 'never' } if (input.idempotent !== true) return { take: false, skip: 'not-idempotent' } if (input.nextAttemptAt) { const dueMs = Date.parse(input.nextAttemptAt) /* 읽을 수 없는 값으로 막지 않는다 — 막으면 그 조치가 표에 갇힌다. */ if (Number.isFinite(dueMs) && input.nowMs < dueMs) return { take: false, skip: 'backing-off' } } if (input.retry === 'after-fix') { /* 접지 않는다(머리말) — 같은 간격으로 계속 본다. */ return { take: true, attempt: attempts + 1, nextAttemptAt: at(input.nowMs + AFTER_FIX_PERIOD_MS) } } /* `again` — 물러섬 표를 다 쓰면 접는다. */ if (attempts >= AGAIN_BACKOFF_MS.length) return { take: false, skip: 'attempts-spent' } const next = AGAIN_BACKOFF_MS[attempts] return { take: true, attempt: attempts + 1, nextAttemptAt: at(input.nowMs + next) } }