Skip to content
isdnetworks
Go back

키를 돌려 쓰려면 죽은 키를 걸러야 했다

외부 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-3key-5 가 상대 쪽에서 정지된 것이었고 계약 변경 때문이었는데 우리가 몰랐다.

이 표가 없었으면 가끔 실패한다로 끝났을 것이다. 어느 키에서 얼마나인지로 바뀌자 원인이 그 자리에서 드러났다.

정리


Share this post on:

Previous Post
자동 복구가 매분 돌고 있었다
Next Post
전환 전에 확인할 셋