외부 API 호출 한도 때문에 키를 여러 개 받아 돌려 쓰고 있었다. 어느 날부터 3분의 1이 실패했다.
Table of contents
Open Table of contents
증상 — 3분의 1이 실패했다
키를 쓰는 자리는 한 줄이었다.
$key = $this->keys[$this->index++ % count($this->keys)];
$this->keys 가 여섯 개였고 요청마다 다음 것을 쓰니 한도가 여섯 배가 된다.
실패율이 3분의 1에서 거의 벗어나지 않았다. 여섯 중 둘이 안 되고 있다는 뜻이라 전부 실패하는 것이 아니라 일부만 실패한다는 것이 단서였다.
원인 — 죽은 키를 계속 썼다
실패를 다루는 자리를 봤다.
$res = $this->call($key, $params);
if ($res->status === 401) {
log_message('error', "키 인증 실패: {$key}");
return null;
}
log_message 로 남기기만 하고 다음 요청에서 또 그 키를 썼다.
index 가 순서대로 돌아가므로 여섯 번에 두 번은 반드시 실패한다. 돌려 쓰는 구조에는 못 쓰게 된 것을 빼는 방법이 함께 있어야 했다.
조치 — 실패한 키를 빼는 장치
next 가 빠진 키를 건너뛰게 고쳤다.
public function next(): ?string {
for ($i = 0; $i < count($this->keys); $i++) {
$key = $this->keys[$this->index++ % count($this->keys)];
if ($this->isDisabled($key)) { continue; }
return $key;
}
return null; // 전부 못 쓴다
}
private function isDisabled(string $key): bool {
return (bool)$this->redis->exists("key:disabled:{$key}");
}
isDisabled 가 참이면 건너뛰고 다음 키로 간다.
401 이나 403 을 받으면 그 자리에서 표시를 건다.
if ($res->status === 401 || $res->status === 403) {
$this->redis->setex("key:disabled:{$key}", 3600, '1');
notify("API 키가 거부됐습니다: " . substr($key, 0, 6) . '...');
}
setex 로 한 시간 동안 빼는데 영구히 빼지 않은 것은 일시적인 문제일 수 있어서였다.
notify 에는 키 앞자리만 넣었다. 전체를 넣으면 알림 경로에 값이 그대로 남는다.
판단 기준 — 응답 코드별 처리
응답 코드마다 뜻이 달랐다.
401 / 403 키가 잘못됐거나 정지됐다 → 한 시간 뺀다
429 한도를 넘었다 → 다음 창까지 뺀다
5xx 상대 문제 → 빼지 않는다. 재시도
429 는 키의 문제가 아니라 그 키의 이번 창 한도를 다 쓴 것이다.
if ($res->status === 429) {
$reset = (int)($res->header('X-RateLimit-Reset') ?? time() + 60);
$this->redis->setex("key:disabled:{$key}", max(1, $reset - time()), '1');
}
X-RateLimit-Reset 이 오면 그때까지만 뺀다.
상대가 언제 풀리는지 알려 주는데 한 시간을 걸어 두면 쓸 수 있는 키를 놀리게 된다. 같은 실패로 묶지 않고 갈라야 하는 이유가 그것이었다.
주의 — 전부 못 쓸 때
여섯 개가 다 빠지면 아무것도 못 한다.
$key = $this->pool->next();
if ($key === null) {
log_message('error', '쓸 수 있는 키가 없습니다');
notify('API 키가 전부 사용 불가 상태입니다');
throw new NoAvailableKey();
}
NoAvailableKey 를 던지고 알린다.
조용히 null 을 내면 그 요청이 그대로 사라지는데 이 상황은 사람이 봐야 한다. 부르는 쪽에서는 이 예외를 받아 재시도 큐에 넣어 요청을 잃지 않게 했다.
사전 준비 — 미리 확인하고 표로 보기
요청이 왔을 때 알게 되는 것은 늦었다.
// 10분마다
foreach ($this->pool->all() as $key) {
$ok = $this->ping($key);
$this->redis->setex("key:health:{$key}", 900, $ok ? '1' : '0');
if (!$ok) { $this->pool->disable($key, 3600); }
}
ping 으로 가볍게 확인해 죽은 키를 실제 요청 전에 뺀다.
ping 도 한도를 쓰므로 자주 하지 않았고 10분에 한 번이면 하루 144번이다.
키별 사용량과 실패 수도 표로 남겼다.
key-1 8,204회 실패 0
key-2 8,190회 실패 0
key-3 0회 비활성 (401)
key-4 8,211회 실패 0
key-5 0회 비활성 (401)
key-6 8,199회 실패 0
key-3 과 key-5 가 상대 쪽에서 정지된 것이었고 계약 변경 때문이었는데 우리가 몰랐다.
이 표가 없었으면 가끔 실패한다로 끝났을 것이다. 어느 키에서 얼마나인지로 바뀌자 원인이 그 자리에서 드러났다.
정리
- 여러 키를 돌려 쓰면 못 쓰게 된 키를 빼는 방법이 있어야 한다
- 실패를 로그에만 남기면 순서가 돌아 또 그 키를 쓴다
- 응답 코드에 따라 다르게 다룬다
- 키 문제와 한도 초과는 성격이 다르다
- 상대가 언제 풀리는지 알려 주면 그때까지만 뺀다
- 전부 못 쓰게 되면 조용히 실패하지 않고 알린다
- 요청은 잃지 않고 재시도 큐에 넣는다
- 요청 전에 미리 확인하되 확인도 한도를 쓰므로 자주 하지 않는다
- 키별 사용량과 실패를 표로 본다