반응형

바쁜 분은 이것만

  1. 빠진 날의 원인은 잠자기, 타임아웃, 서버 503 세 가지
  2. urlopen을 20·60·120초 뒤 재시도, HTTP는 429·5xx만
  3. launchd를 하루 3회로, 이미 받은 건 건너뛰고 빈 것만 채우기

이전 글에서 로스트아크 오픈 API 거래소 시세를 매일 모으는 스크립트를 만들고, crontab이 잠자기 중 건너뛰는 문제로 launchd로 옮겼어요. 그런데 데이터를 세어 보니 잠자기 말고도 빈 날이 있었습니다.

1. 어느 날이 비었나: CSV부터 세어 보기

시세가 쌓이는 market_history.csv에서 카테고리 3개(강화 재료·각인서·생활)가 안 되는 날을 뽑았어요.

$ .venv/bin/python -c "
import csv, datetime as dt
cats = {}
for r in csv.DictReader(open('lostark/data/market_history.csv', encoding='utf-8')): cats.setdefault(r['date'], set()).add(r['category'])
for i in range(20):
    d = (dt.date(2026, 10, 1) + dt.timedelta(i)).isoformat()
    if len(cats.get(d, ())) < 3: print(d, sorted(cats.get(d, [])) or '없음')
"
2026-10-02 없음
2026-10-04 ['각인서', '강화 재료']
2026-10-07 없음
2026-10-14 없음

10/2, 10/7, 10/14는 아예 없고, 10/4는 생활 카테고리가 통째로 빠졌습니다.

2. 로그로 본 원인 3가지

출력과 에러를 cron.log로 보내 두었기 때문에 빈 날 근처를 보면 바로 보여요.

① 잠자기 (10/2) — 이날은 로그에 아무 줄도 없습니다. 이전 글에서 pmset 로그로 확인한, 최대 절전 중 crontab이 건너뛴 날이에요. launchd로 옮기며 해소됐습니다.

② 네트워크 타임아웃 (10/4, 10/14)

# 10/4 — 생활 카테고리 받는 중
  File "~/blog-auto/lostark/fetch.py", line 74, in snapshot
    for it in market_all(c["CategoryCode"]):
...
TimeoutError: The read operation timed out

# 10/14 — 첫 요청(공지)부터
  File "~/blog-auto/lostark/fetch.py", line 66, in snapshot
...
urllib.error.URLError: <urlopen error timed out>

둘 다 타임아웃인데 단계가 달라요. 10/4는 응답을 읽다가, 10/14는 sock.connect 단계에서 시간이 지나 URLError로 감싸져 올라왔어요. 옛 코드는 예외를 잡지 않아 10/4는 두 카테고리만 저장된 채 끝났고, 다음 실행은 다음 날이었습니다.

③ 서버 503 (10/7)

/news/notices → HTTP 503 b''

10/7과 10/14는 둘 다 수요일이에요. 로스트아크 정기점검이 보통 수요일 오전이라, 점검 시간과 겹친 것이 가장 유력한 원인으로 보입니다(확인된 건 아니에요).

3. 고친 점 ①: urlopen 재시도

잠시 뒤 다시 하면 되는 오류들이라 요청 함수에 재시도를 넣었어요.

RETRY_WAIT = [20, 60, 120]   # 실패 시 기다릴 초 (잠깬 직후 네트워크 미연결, 일시 오류 대비)
RETRY_HTTP = {429, 500, 502, 503, 504}   # 503: 수요일 정기점검 중에도 나옴

        try:
            with urllib.request.urlopen(req, timeout=30) as r:
                return json.loads(r.read() or "null")
        except urllib.error.HTTPError as e:
            if e.code not in RETRY_HTTP:
                raise FetchFailed(f"{path} → HTTP {e.code} {e.read()[:200]!r}")
            err = f"HTTP {e.code}"
        except (urllib.error.URLError, TimeoutError, ConnectionError, OSError) as e:
            err = f"{type(e).__name__}: {e}"
        if attempt < len(RETRY_WAIT):
            print(f"{dt.datetime.now():%H:%M:%S} {path} 실패({err}) → {RETRY_WAIT[attempt]}초 후 재시도", flush=True)
            time.sleep(RETRY_WAIT[attempt])
    raise FetchFailed(f"{path} → {err} (재시도 {len(RETRY_WAIT)}회 모두 실패)")
  • HTTPError를 URLError보다 먼저 잡아야 해요. HTTPError가 URLError의 하위 클래스라 순서가 바뀌면 401·404처럼 다시 해도 안 되는 코드까지 재시도해요.
  • 타임아웃도 20초에서 30초로 늘렸어요(옛 로그에 timeout=20).

테스트는 진짜 API를 반복해 부르지 않게 했어요. BASE를 없는 호스트(unreachable.invalid)로, RETRY_WAIT를 [1, 1, 1]로 바꿔 돌린 마지막 두 줄입니다.

$ .venv/bin/python plan/ref_code/launchd_retry/test_a_unreachable.py | tail -2
21:36:55 /news/notices 실패(URLError: <urlopen error [Errno 8] nodename nor servname provided, or not known>) → 1초 후 재시도
FetchFailed: /news/notices → URLError: <urlopen error [Errno 8] nodename nor servname provided, or not known> (재시도 3회 모두 실패)

다음은 urlopen을 가짜 함수로 바꿔 503을 두 번 낸 뒤 성공하게 했어요.

21:34:51 /news/notices 실패(HTTP 503) → 1초 후 재시도
21:34:52 /news/notices 실패(HTTP 503) → 1초 후 재시도
결과: [{'Title': '점검 완료'}] / 호출 횟수: 3

4. 고친 점 ②: 하루 3회 실행 + 이미 받았으면 건너뛰기

재시도는 몇 분이면 끝나서 점검이 길어지면 부족합니다. 그래서 launchd를 09:10, 12:40, 21:10 세 번 부르게 바꿨어요.

	<key>StartCalendarInterval</key>
	<array>
		<dict>
			<key>Hour</key>
			<integer>9</integer>
			<key>Minute</key>
			<integer>10</integer>
		</dict>
		<dict>
			<key>Hour</key>
			<integer>12</integer>
			<key>Minute</key>
			<integer>40</integer>
		</dict>
		<dict>
			<key>Hour</key>
			<integer>21</integer>
			<key>Minute</key>
			<integer>10</integer>
		</dict>
	</array>
$ plutil -lint ~/Library/LaunchAgents/com.blogauto.lostark-snapshot.plist
~/Library/LaunchAgents/com.blogauto.lostark-snapshot.plist: OK

plist를 고친 뒤에는 내렸다 다시 올려야 반영돼요. 올린 뒤 launchctl print gui/$(id -u)/com.blogauto.lostark-snapshot를 보면 descriptor 항목이 세 개(21:10, 12:40, 9:10) 나옵니다.

launchctl bootout gui/$(id -u)/com.blogauto.lostark-snapshot
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.blogauto.lostark-snapshot.plist

그대로면 같은 날 시세가 세 번 쌓이니, 오늘 이미 받은 카테고리는 빼고 빈 것만 받게 했습니다. 카테고리 하나를 끝까지 받은 다음에만 CSV에 쓰고, 일부라도 실패하면 exit 1로 끝내요.

    if hist.exists():
        done = {r["category"] for r in csv.DictReader(hist.open(encoding="utf-8")) if r["date"] == today}
    ...
    todo_mkt = [c for c in WATCH if c["label"] not in done]
    if not todo_json and not todo_mkt:
        print(f"{dt.datetime.now():%H:%M:%S} {today} 이미 수집됨 — 건너뜀")
        return

임시 폴더에서 call을 가짜로 바꿔, 첫 실행에서만 생활이 실패하게 하고 세 번 돌렸어요.

--- 1번째 실행
21:34:53 2026-10-20 일부 실패 — 다음 예약 시각에 빠진 것만 다시 받음: 생활: /markets/items → TimeoutError: The read operation timed out (재시도 3회 모두 실패)
exit code: 1
CSV: {'강화 재료': 1}
--- 2번째 실행
21:34:53 저장: <임시폴더>/2026-10-20 / <임시폴더>/market_history.csv
CSV: {'강화 재료': 1, '생활': 1}
--- 3번째 실행
21:34:53 2026-10-20 이미 수집됨 — 건너뜀
CSV: {'강화 재료': 1, '생활': 1}

두 번째는 생활만 받아 중복이 없어요. 실제 cron.log에도 이제 하루 첫 회차만 "저장", 나머지는 "건너뜀"이 찍히고, 직접 돌려도 같습니다.

$ .venv/bin/python lostark/fetch.py snapshot
21:35:03 2026-10-20 이미 수집됨 — 건너뜀

5. 지난 빈 날은 못 채운다

거래소 API는 전일 평균가·최근 거래가·현재 최저가만 주기 때문에 지난 날짜 시세는 나중에 받을 수 없어요. 빈 날은 빈 채로 둡니다. 그날 받아야 하는 데이터라면 재시도와 여러 번 실행을 처음부터 넣어 두는 게 좋아요.

정리

빈 날 로그 대응
10/2 기록 없음 (잠자기) launchd로 이전 (깨면 실행)
10/4 생활 TimeoutError: The read operation timed out 재시도 + 빠진 카테고리만 다시 받기
10/7 (수) HTTP 503 503 재시도 + 12:40·21:10 회차
10/14 (수) <urlopen error timed out> 재시도 + 하루 3회

참고: Apple launchd.plist(5) man page — StartCalendarInterval / Python 공식 문서 — urllib.request, urllib.error (HTTPError는 URLError의 하위 클래스)

반응형

+ Recent posts