カード拒否にも三回ずつ送り直していた
한국어 원문으로 표시합니다.
목표
노드에 RetryPolicy 를 붙이고, 다시 해도 되는 실패만 다시 하게 만든다. 같은 요청이 두 번 반영되지 않게 멱등 열쇠를 붙이고, 호출 예산을 세어 바닥나면 재시도 대신 포기하되 결과를 남긴다. 판정은 전부 횟수로 한다 — 시간이나 속도는 재지 않는다.
왜 중요한가
재시도는 한 줄로 켜진다. 그래서 켜 놓고 확인하지 않는다. 그런데 RetryPolicy 의 기본 retry_on 은 ValueError·TypeError·RuntimeError·OSError 같은 예외를 다시 하지 않는다(이 실습 환경의 langgraph 0.2.60 에서 확인한 사실입니다). 우리가 만드는 예외는 대개 그 계열을 물려받으므로, 정책을 붙여 놓고도 시도 기록에는 한 줄만 남는다. 오류도 경고도 나지 않는다.
반대로 retry_on 을 넓게 잡으면 영구 실패까지 상한까지 반복한다. 카드 거절 한 건에 시도가 세 줄 쌓이고, 그만큼이 정상 건이 쓸 몫을 먹는다. 그래서 "다시 해도 되는 실패" 를 가르는 기준이 필요하다 — 같은 요청을 그대로 다시 보냈을 때 다른 답이 나올 가능성이 있는가.
재시도가 안전하려면 조건이 하나 더 있다. 시간이 지나 끊긴 요청은 응답을 못 받았을 뿐 저쪽에서 이미 처리됐을 수 있다. 그대로 다시 보내면 두 번 청구된다. 요청마다 열쇠를 붙이고 받는 쪽이 그 열쇠로 "이미 한 건인가" 를 봐야 한다.
마지막으로 재시도는 그대로 호출 예산을 먹는다. 앞 건이 흔들리기만 해도 뒤 건은 시도조차 못 한다. 예산은 건수가 아니라 도구 몸통이 돈 횟수로 세고, 바닥났을 때는 예외를 올리는 대신 결과 기록의 한 줄로 남긴다.
채점기는 여러분이 적어 둔 설명을 믿지 않는다. 여러분의 모듈을 실제로 불러 매번 다른 열쇠·금액·실패 계획으로 돌리고, ATTEMPTS 에 쌓인 줄 수와 원장에 반영된 금액을 채점기가 따로 계산한 값과 대조한다.
단계
- /root/work/agbudget/budget.py 에
MAX_ATTEMPTS·TransientError·PermanentError·ATTEMPTS·LEDGER·PLAN·reset()·applied_total()·charge()·State·build_plain()·run_once()를 만드세요. 재시도가 없으므로 실패하면 시도는 한 번뿐입니다. DEFAULT_RETRY·build_default_retry()·run_default()를 더하세요.retry_on을 적지 않은RetryPolicy를 노드에 붙였는데도 시도가 여전히 한 번인 것을 확인합니다.RETRY_ALL·build_retry_all()·run_retry_all()을 더하세요.retry_on=(ValueError,)를 명시하면 일시 실패가 되살아나고, 상한을 넘기면 마지막 예외가 그대로 올라옵니다.is_retryable(exc)·RETRY_SPLIT·build_split()·run_split()을 더해 영구 실패를 다시 하지 않게 하세요. 영구 실패의 시도 횟수가MAX_ATTEMPTS에서 1 로 줄어야 합니다.charge()에 멱등 열쇠를 넣으세요. 같은request_key로 두 번 불려도 원장에는 한 줄만 남고, 두 번째 호출은 그때 그 영수증을duplicate표시와 함께 돌려줍니다.BudgetExhausted·call_with_budget()·build_budgeted()·run_budgeted()를 더하세요. 예산이 남지 않으면 도구를 부르지 않고, 포기하더라도outcome이"예산초과"인 기록을 남깁니다.settle_batch(orders, budget)를 더해 여러 건이 예산 하나를 나눠 쓰게 하세요. 예산이 바닥난 뒤의 건은skipped로 남깁니다.- /root/work/agbudget/budget_report.json 과 /root/work/agbudget/budget_report.md 에 재어 본 횟수를 기록하세요.
참고
- 실행 계약: 채점기는
/root/work/agbudget/budget.py를 파이썬 모듈로 불러MAX_ATTEMPTS·ATTEMPTS·LEDGER·PLAN·reset·charge·applied_total·TransientError·PermanentError·build_plain·run_once와 뒤 단계에서 더하는 이름들을 직접 씁니다. 스크립트로 실행하지 않으므로if __name__ == "__main__"은 없어도 됩니다. reset(plan)은ATTEMPTS·LEDGER를 비우고PLAN을plan으로 바꿉니다.plan은{request_key: 남은 실패 횟수}또는{request_key: "permanent"}입니다. 채점기가 매번 다른plan을 넣습니다.charge(request_key, amount)는 부를 때마다ATTEMPTS에request_key를 한 줄 덧붙입니다.PLAN이 정수 n(n 이 0보다 큼)이면TransientError를 내고 n 을 하나 줄이며,"permanent"면PermanentError를 냅니다. 그 밖에는 원장에{"request_key", "amount", "seq"}를 한 줄 남기고duplicate를 붙여 돌려줍니다.seq는 1부터 하나씩 올라갑니다.run_once·run_default·run_retry_all·run_split은 모두{"ok", "error", "attempts", "receipt", "outcome"}를 돌려줍니다.attempts는len(ATTEMPTS)이고,error는 실패했을 때 예외 클래스 이름(예:"TransientError"), 성공이면 빈 문자열입니다. 이 함수들은reset()을 부르지 않습니다 — 초기화는 부르는 쪽이 합니다.run_budgeted(request_key, amount, budget)는 위 열쇠에 더해budget과applied를 담고,outcome이"완료"·"예산초과"·"실패"중 하나입니다.settle_batch(orders, budget)의orders는[(request_key, amount), ...]이고 돌려주는 값은{"done", "failed", "skipped", "calls", "applied", "budget"}입니다.done·failed·skipped에는request_key만 순서대로 담습니다.MAX_ATTEMPTS는 3 으로 둡니다. 총 시도 횟수이지 추가 재시도 횟수가 아닙니다.- 대기 시간은
initial_interval=0.01, backoff_factor=1.0, jitter=False로 아주 작게 두세요. 기본값(0.5초에서 두 배씩)이면 실습이 느려집니다. 채점기는 시간을 재지 않고 횟수만 봅니다. RetryPolicy의retry_on에는 예외 클래스의 튜플도,(예외) -> bool함수도 줄 수 있습니다.- 8단계 기록에 쓸 배치 상황은 이렇게 고정합니다:
orders = [("A-1", 1500), ("A-2", 2300), ("A-3", 900)],plan = {"A-1": 2},budget = 4. - 이 파드에는 인터넷이 없습니다.
pip install은 되지 않습니다. langgraph 0.2.60 이 이미 들어 있습니다(python3 -c "import langgraph"). - 공식 문서: Types 레퍼런스 · Graph API overview · Use the graph API
- 흔한 실수:
retry_on을 적지 않고 재시도가 될 것이라 믿기,max_attempts를 추가 재시도 횟수로 알기, 노드 안에서try/except로 예외를 삼켜 재시도가 아예 걸리지 않게 하기, 멱등 열쇠를 받는 쪽에서 새로 만들기, 예산을 건수로 세기.
재시도가 없으면 시도는 한 번뿐이다
/root/work/agbudget/budget.py 에 MAX_ATTEMPTS·TransientError·PermanentError·ATTEMPTS·LEDGER·PLAN·reset()·applied_total()·charge()·State·build_plain()·run_once() 를 만드세요. build_plain() 은 재시도가 붙지 않은 그래프를 compile() 해 돌려줍니다.
charge 는 부를 때마다 ATTEMPTS 에 한 줄을 먼저 덧붙인 뒤 PLAN 을 봅니다 — 실패한 시도도 세야 하기 때문입니다. 노드는 실패를 삼키지 말고 예외를 그대로 내보내세요. 노드 안에서 try/except 로 잡아 버리면 재시도가 아예 걸리지 않습니다. run_once 는 invoke 를 try/except 로 감싸 예외를 기록으로 바꿉니다. 노드 이름과 상태 열쇠가 겹치면 컴파일이 죽으니 서로 다르게 두세요.
정책을 붙였는데 왜 한 번만 하는가
DEFAULT_RETRY = RetryPolicy(max_attempts=MAX_ATTEMPTS, initial_interval=0.01, backoff_factor=1.0, jitter=False) 와 build_default_retry()·run_default() 를 더하세요. retry_on 은 적지 않습니다. 일시 실패 한 건을 돌려도 시도가 여전히 1회인 것을 확인합니다.
from langgraph.types import RetryPolicy 를 쓰고 add_node(..., retry=DEFAULT_RETRY) 로 붙입니다. 이 단계는 일부러 안 되는 것을 재현하는 것입니다 — 기본 retry_on 은 ValueError 계열을 다시 하지 않습니다. 채점기는 노드에 정책이 실제로 붙었는지(compile() 한 그래프의 nodes["settle"].retry_policy)와 시도 횟수를 함께 봅니다. 그러니 build_plain() 을 그대로 돌려주면 안 됩니다.
다시 할 것을 직접 적는다
RETRY_ALL = RetryPolicy(retry_on=(ValueError,), max_attempts=MAX_ATTEMPTS, ...) 와 build_retry_all()·run_retry_all() 을 더하세요. 일시 실패 n회 뒤에 성공하면 시도는 n+1회이고, MAX_ATTEMPTS 를 넘기면 마지막 예외가 그대로 올라옵니다.
retry_on 에는 예외 클래스의 튜플을 줍니다 — 클래스 하나만 줄 때도 쉼표를 빠뜨리지 마세요. max_attempts 는 총 시도 횟수라서 3 이면 처음 한 번에 다시 두 번입니다. 상한을 다 쓰고도 실패하면 재시도했다는 표시가 붙지 않은 마지막 예외가 그대로 나옵니다. 그 흔적은 ATTEMPTS 를 세어 직접 남겨야 합니다.
영구 실패를 세 번 보내지 않는다
is_retryable(exc) 를 만들고 RETRY_SPLIT = RetryPolicy(retry_on=is_retryable, ...)·build_split()·run_split() 을 더하세요. 영구 실패의 시도 횟수가 MAX_ATTEMPTS 에서 1 로 줄어야 합니다.
retry_on 에는 (예외) -> bool 함수도 줄 수 있습니다. 가르는 기준은 하나입니다 — 같은 요청을 그대로 다시 보내면 다른 답이 나올 가능성이 있는가. 다시 해도 되는 종류만 참으로 두고 모르는 예외는 거짓으로 두세요. 참을 기본값으로 삼으면 영구 실패가 새 이름으로 올 때마다 조용히 새어 나갑니다. 채점기는 같은 영구 실패를 run_retry_all 과 run_split 으로 각각 돌려 시도 횟수를 견줍니다.
두 번 와도 한 번만 반영한다
charge() 가 같은 request_key 를 두 번 받으면 원장에 새 줄을 만들지 않고 그때 그 영수증을 duplicate 가 참인 채로 돌려주게 하세요. 원장 줄 수와 applied_total() 이 두 배가 되면 안 됩니다.
열쇠로 원장을 먼저 훑어 이미 있는지 봅니다. seq 도 그대로여야 합니다 — 새 번호를 매기면 같은 건이 두 건으로 보입니다. 실패를 내는 자리보다 뒤에서 찾아야 합니다. 열쇠는 부르는 쪽이 정한 값이라는 점이 중요합니다. 채점기는 서로 다른 두 건과 그중 하나의 중복 전달을 섞어 넣고, 원장 줄 수·합계·seq 를 함께 봅니다.
바닥나면 포기하되 기록을 남긴다
BudgetExhausted·call_with_budget(request_key, amount, budget)·build_budgeted()·run_budgeted(request_key, amount, budget) 를 더하세요. len(ATTEMPTS) 가 budget 이상이면 도구를 부르지 않고, 그 경우 outcome 이 "예산초과" 인 기록을 돌려줍니다.
예산은 건수가 아니라 도구 몸통이 돈 횟수입니다. BudgetExhausted 를 is_retryable 이 참으로 보지 않게 두세요 — 예산이 없어서 난 실패를 다시 해 봐야 소용없습니다. 예산 검사는 charge 를 부르기 전이어야 시도가 한 칸도 더 늘지 않습니다. run_budgeted 는 예외를 밖으로 내보내지 말고 outcome 이 담긴 기록으로 바꾸세요.
앞 건의 재시도가 뒤 건의 몫을 먹는다
settle_batch(orders, budget) 를 더하세요. 여러 건이 예산 하나를 나눠 쓰고, 예산이 남지 않으면 남은 건은 도구를 부르지 않고 skipped 에 담습니다. 돌려주는 값은 {"done", "failed", "skipped", "calls", "applied", "budget"} 입니다.
건마다 reset() 을 부르면 안 됩니다 — 예산은 배치 전체가 나눠 쓰는 것이라 ATTEMPTS 가 이어져야 합니다. 건을 시작하기 전에도 남은 예산을 한 번 보고, 없으면 run_budgeted 를 아예 부르지 마세요. done·failed·skipped 에는 request_key 만 순서대로 담습니다. 채점기는 같은 규칙을 따로 계산해 목록과 calls 를 대조합니다.
재어 본 횟수로 기록한다
/root/work/agbudget/budget_report.json 에 max_attempts·no_retry_attempts·default_policy_attempts·retry_all_permanent_attempts·split_permanent_attempts·split_transient_attempts·duplicate_delivery_entries·batch_budget·batch_calls·batch_done·batch_skipped·batch_applied 를, /root/work/agbudget/budget_report.md 에 ## 무엇을 다시 해도 되는가 ## 상한을 어디에 두었나 ## 같은 요청이 두 번 와도 안전한 이유 ## 예산이 바닥났을 때 무엇을 남기는가 네 절로 쓰세요.
숫자는 손으로 적지 말고 여러분의 모듈을 실제로 돌려 얻은 값으로 채우세요. split_transient_attempts 는 일시 실패 2회 뒤 성공했을 때의 시도 횟수이고, duplicate_delivery_entries 는 같은 열쇠로 두 번 보낸 뒤 원장에 남은 줄 수입니다. 배치는 지시문 참고 절이 정한 orders·plan·budget 그대로 돌리세요. 채점기는 같은 것을 따로 계산해 대조합니다.