멱등성 — 두 번 눌러도 한 번만 결제되게 · 서명은 맞지만 같은 알림이 또 왔다 · 실습
서명은 맞지만 같은 알림이 또 왔다
목표
원문 서명·시간 창·이벤트 중복 제거를 하나의 웹훅 처리 경로로 연결합니다.
왜 중요한가
결제 제공자가 응답을 받지 못해 같은 이벤트를 다시 보냈다. 서버는 서명이 맞으니 정상 요청이라고 생각하고 매출을 다시 더했다. 서명은 누가 보냈는지에 관한 증거이지 처음 처리하는 요청이라는 증거가 아니다. 재전송을 허용하는 시간 창과 저장된 이벤트 id를 각각 확인해야 한다.
단계
1. /root/work/idem-webhook-lab/service.py에서 signed_bytes(timestamp, body)는 bool 제외 int timestamp와 bytes body만 받아 str(timestamp).encode()+b'.'+body를 반환합니다. 잘못된 타입은 ValueError입니다.
처음 한 번 준비하세요. 기존 파일은 덮어쓰지 않습니다.
mkdir -p /root/work/idem-webhook-labtest -e /root/work/idem-webhook-lab/service.py || cp /opt/fixtures/ten_labs/idem-webhook-lab/service.py /root/work/idem-webhook-lab/service.pycd /root/work/idem-webhook-lab2. /root/work/idem-webhook-lab/service.py에서 signature(secret, timestamp, body)는 bytes secret으로 signed_bytes에 HMAC-SHA256을 적용한 hex 문자열입니다.
3. /root/work/idem-webhook-lab/service.py에서 verify(secret, timestamp, body, supplied)는 supplied가 str이며 계산한 서명과 compare_digest로 같을 때 True, 아니면 False입니다.
4. /root/work/idem-webhook-lab/service.py에서 fresh(timestamp, now, tolerance=300)는 timestamp와 now가 bool 제외 int이고 abs(now-timestamp)<=tolerance면 True입니다. 나머지는 False입니다. tolerance는 호출자가 주는 양의 int입니다.
5. /root/work/idem-webhook-lab/service.py에서 init_db(path)는 inbox(id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL)와 total(id INTEGER PRIMARY KEY, amount INTEGER NOT NULL)을 만들고 total에 id=1,amount=0을 중복 없이 넣습니다.
6. /root/work/idem-webhook-lab/service.py에서 apply(path, event, fault=lambda:None)는 event의 id가 비어 있지 않은 str, amount가 bool 제외 양의 int인지 검증합니다. id·amount를 정규 JSON으로 지문 계산합니다. 같은 id/지문은 False, 다른 지문은 ValueError, 새 이벤트는 inbox 삽입→fault()→합계 증가 후 True입니다.
7. /root/work/idem-webhook-lab/service.py에서 total(path)는 total의 id=1인 amount 정수를 반환합니다.
8. /root/work/idem-webhook-lab/service.py에서 create_app(path, secret, clock)는 POST /webhook에서 원문 body, X-Timestamp, X-Signature를 읽습니다. 시각 형식·시간 창·서명 실패는 401, JSON 파싱 실패는 400, apply의 ValueError는 409입니다. 정상은 200 {accepted:True, duplicate:첫처리면False}입니다.
참고
- 인터넷과 패키지 설치 없이 기존 lab-dev 환경에서 수행합니다.
- 각 단계는 45초 채점 예산 안에서 실행됩니다. 실제 sleep이나 네트워크 호출을 추가하지 마세요.
- 채점은 제출 모듈을 새로 불러오고 독립 입력과 임시 DB로 검사합니다. 예상값을 상수로 반환하는 대신 계약을 구현하세요.
- [FastAPI 공식 문서](https://fastapi.tiangolo.com/) · [pytest 공식 문서](https://docs.pytest.org/en/stable/) · [Python sqlite3](https://docs.python.org/3/library/sqlite3.html)
- 한계: 이 서명 형식은 교육용 프로토콜이며 실제 결제 제공자의 규격을 대신하지 않는다. 학습용 secret만 사용한다. 서버 시계의 신뢰성, 키 회전, 허용 본문 크기와 영구 보관 기간은 별도 운영 과제다. 중복 제거는 같은 이벤트 id와 같은 본문에 적용되고 다른 본문으로 같은 id를 재사용하면 충돌이다.
8단계
- 서명할 원문을 보존한다
- HMAC을 계산한다
- 잘못된 서명을 거절한다
- 과거와 미래의 재생을 제한한다
- 이벤트와 효과를 함께 저장할 준비를 한다
- 기록과 매출 사이의 실패를 롤백한다
- 별도 연결에서 합계를 읽는다
- 실제 웹훅 요청을 처리한다