속도 제한 및 할당량 웹훅 알림¶
notifications: 섹션은 라우터가 이미 내리는 속도 제한 및 할당량 거부를 애플리케이션 소유 웹훅으로 푸시합니다. 대상 독자는 운영자가 아니라 API 키 뒤의 애플리케이션입니다. 운영자 알림은 별도의 alerting: 섹션(이슈 #2356)이 담당합니다. 이 기능은 옵트인 방식이며 기본값은 비활성화입니다(이슈 #2416).
범위¶
활성화하면 라우터는 다음 이벤트에 대해 서명된, 크기가 제한된 JSON 이벤트를 설정된 각 대상으로 전송합니다.
rate_limit_exceeded: 로컬 속도 제한 또는 예산 거부, 허브 티어의 분당 한도를 포함합니다.quota_low: 허브에서 동기화된 월 예산에서quota_low_fraction을 처음 넘는 시점입니다.quota_exhausted: 허브 월 예산 소진 또는 로컬 키 토큰 예산 소진입니다.
전송은 전적으로 요청 경로 밖에서 실행됩니다. 프로듀서는 원자적 활성화 마스크로 판단하고, 반복을 병합하며, 일치하는 대상마다 논블로킹 try_send를 한 번 수행합니다. 어떤 프로듀서도 await하지 않고 네트워크에 접근하지 않으므로, 느리거나 실패하는 수신자가 요청을 지연시키거나 실패시킬 수 없습니다.
notifications: 섹션이 없거나 enabled: false이면 태스크를 생성하지 않고, 이벤트를 대기열에 넣지 않으며, 요청도 보내지 않습니다. 섹션을 활성화했는데 대상이 없으면 허용되며 아무 동작도 하지 않고, 시작 시 이 사실을 한 번 기록합니다.
설정¶
notifications:
enabled: true
targets:
- name: "app-owner"
url: "https://hooks.example.com/continuum"
signing_secret: "${WEBHOOK_SIGNING_SECRET}"
events: [rate_limit_exceeded, quota_low, quota_exhausted]
keys: []
timeout: "5s"
allow_private_network: false
quota_low_fraction: 0.9
cooldown: "1m"
max_queue: 256
retry:
max_attempts: 3
initial_delay: "500ms"
max_delay: "30s"
이 섹션은 활성화 여부와 관계없이 검증됩니다. 따라서 잘못된 값은 활성화 시점이 아니라 로드 시점에 실패합니다. 재로드 등급은 섹션과 대상 모두 immediate입니다.
| 필드 | 타입 | 기본값 | 설명 |
|---|---|---|---|
enabled | bool | false | 전체 스위치입니다. |
targets[].name | string | 없음 | 메트릭 레이블이며, 출력 가능한 ASCII 1~64자입니다. 이름은 레이블일 뿐이므로 중복을 허용합니다. |
targets[].url | string | 필수 | https만 허용하며 자격 증명을 포함할 수 없고, SSRF 검사를 거칩니다. 경로에 수신자 토큰이 들어갈 수 있으므로 설정 출력에서 마스킹됩니다. |
targets[].signing_secret | string | 없음 | HMAC-SHA256 키입니다. 설정 출력에서 마스킹되고 로그에 남지 않습니다. 로더가 ${ENV_VAR}를 치환합니다. |
targets[].events | enum 배열 | 세 가지 모두 | rate_limit_exceeded, quota_low, quota_exhausted입니다. 알 수 없는 이름은 설정 오류입니다. |
targets[].keys | string 배열 | [] | 키 ID 허용 목록이며, 각 항목은 1~128바이트입니다. 비어 있으면 모든 키와 일치하고, 값이 있으면 목록에 있는 키의 식별된 이벤트만 받습니다. |
targets[].timeout | duration | "5s" | 전송 시도 한 번의 상한입니다. |
targets[].allow_private_network | bool | false | 클러스터 내부 수신자를 위해 루프백 및 사설 주소 차단 목록을 해제합니다. https 요구는 유지됩니다. |
quota_low_fraction | float, 0~1 | 0.9 | 0이면 quota_low를 비활성화합니다. |
cooldown | duration | "1m" | 이벤트와 대상별 병합 구간입니다. 구간 안의 반복은 전송하지 않고 집계합니다. |
max_queue | integer, 1~65536 | 256 | 대상별 대기열 크기 상한입니다. 대기열이 가득 차면 해당 대상의 이벤트를 버리고 집계합니다. |
retry.max_attempts | integer, 1~10 | 3 | 이벤트와 대상별 시도 횟수이며 첫 시도를 포함합니다. |
retry.initial_delay | duration | "500ms" | 첫 백오프 지연입니다. |
retry.max_delay | duration | "30s" | 백오프 증가의 상한이며 initial_delay 이상이어야 합니다. |
quota_low_fraction은 백분율이 아니라 비율입니다. 0.9는 월 예산의 90퍼센트를 뜻합니다.
이벤트 소스¶
| 이벤트 | 소스 | 설명 |
|---|---|---|
rate_limit_exceeded | 로컬 속도 제한기(rate_limit_v2)의 일반 429 | 요청 속도, 토큰 예산, 인플라이트, 용량 거부를 포함합니다. 페이로드는 제한 유형과 Retry-After를 담습니다. 공유 버킷의 수치는 응답이 감추는 것과 동일하게 감춥니다. |
rate_limit_exceeded | 허브 티어 분당 한도(control_plane::enforcement) | rpm, input-tpm, output-tpm입니다. |
quota_exhausted | 로컬 범위 예산 엔진 | 소진된 budgets: 범위/윈도에 대해 발생합니다. |
quota_exhausted | 레거시 api_keys[].max_tokens_budget | 같은 키를 범위 엔진도 관찰하면 함께 발생하며, 쿨다운이 두 이벤트를 병합합니다. |
quota_exhausted | 허브 티어 월 한도 | monthly-tokens, monthly-requests입니다. |
quota_exhausted | 허브 코스트 센터 및 모델 예산 거부 | 범위와 차원 또는 모델 이름과 함께 발생하며 수치는 담지 않습니다. |
quota_low | 허브 월 예산 검사 | 키와 달력 월당 한 번, quota_low_fraction을 처음 넘을 때 발생합니다. 알림기의 쿨다운은 보조 계층이며 주된 보호 장치가 아닙니다. |
StoreUnavailable 또는 TrackerFull 거부는 호출자의 할당량이 아니라 내부 실패이므로 알림 이벤트가 아닙니다. 백엔드에서 프록시된 429는 제공자 측 할당량이며 라우터 이벤트가 아니므로 절대 발생하지 않습니다.
전송 계약¶
각 대상은 크기가 제한된 대기열(max_queue)과 전송 태스크 하나를 가집니다. 따라서 응답하지 않는 엔드포인트가 다른 대상의 처리를 막을 수 없습니다.
- 프로듀서는 이벤트와 대상별로
"{event}:{key_id-or-unattributed}"형식의 중복 제거 키를 하나 계산합니다. 쿨다운 구간 안의 발생은notification_coalesced_total에 집계하고 전송하지 않으며, 구간이 지난 뒤의 첫 이벤트가 억제 횟수를 담습니다. 쿨다운 맵은 이벤트와 대상별로 항목 하나를 유지합니다. 만료된 항목은 맵이 소프트 크기를 넘으면 정리되지만 하드 상한은 없으므로, 메모리는 한 구간 안에 관측된 서로 다른 키의 수를 따릅니다. - 이벤트는 해당 클래스를 구독하고
keys필터가 일치하는 모든 대상에 제시됩니다. 빈 필터는 모든 키와 일치하며, 키가 식별되지 않은 이벤트는 빈 필터를 가진 대상에만 도달합니다. - 대기열이 가득 차면 해당 대상의 이벤트를 버리고
notification_dropped_total{target}에 집계합니다. 프로듀서를 차단하지 않습니다. - 각 시도는 대상 타임아웃으로 제한됩니다. 2xx가 아닌 상태나 전송 오류는
retry.max_delay로 상한을 둔 지수 백오프로retry.max_attempts까지 재시도합니다. 2xx를 받으면 이벤트를 종료합니다. - 재로드가 반영되면 대상 목록을 교체합니다. 이미 대기열에 들어간 이벤트는 이를 받아들인 대상을 유지하며, 이전 태스크는 남은 이벤트를 처리하고 종료합니다. 종료 시 태스크는 5초가 지나면 새 전송을 시작하지 않으며, 이미 진행 중인 전송 하나는 자체 타임아웃과 재시도 예산 안에서 그 뒤에 끝날 수 있습니다.
- 이벤트 ID는 한 번 생성되어 재시도에서도 유지되므로 수신자가 중복 제거에 사용할 수 있습니다.
페이로드¶
{
"id": "router-1-67c2f0a1-3",
"event": "rate_limit_exceeded",
"occurred_at": "2026-11-03T10:00:00Z",
"router_id": "router-1",
"subject": {"key_id": "key-42"},
"detail": {
"limit_type": "rpm",
"retry_after_seconds": 30,
"limit": 600,
"remaining": 0,
"window": "minute"
},
"suppressed": 0
}
subject.key_id는 키 식별자이며 키를 알 수 없으면 생략합니다. 키 값 자체는 절대 담지 않습니다. detail은 고정된 이벤트 및 한도 이름과 범위가 제한된 수치만 담습니다. 모든 수치는 u64::MAX의 절반으로 제한하고, 자유 형식 문자열은 64바이트로 제한하거나 unknown으로 바꾸며, 키 ID는 128바이트로 제한합니다. API 키 자료, 프롬프트나 완성 텍스트, 허브 토큰, 요청 헤더는 절대 포함하지 않습니다.
detail 안에서 dimension은 프로듀서가 아는 경우 거부한 로컬 토큰 또는 인플라이트 차원(api_key, global, user, team 등)을 나타내고, window는 예산 구간(minute, day, monthly)을 나타냅니다. 두 값은 별도 필드이므로 window가 차원 이름을 담는 일은 없습니다.
서명¶
signing_secret이 설정되면 모든 전송에 다음 헤더가 붙습니다.
Content-Type: application/json
X-Continuum-Event: rate_limit_exceeded
X-Continuum-Delivery: router-1-67c2f0a1-3
X-Continuum-Timestamp: 1793709600
X-Continuum-Signature: sha256=<lowercase hex HMAC-SHA256(secret, "{timestamp}.{body}")>
본문은 바이트로 한 번 직렬화하고 서명한 뒤 그대로 전송하므로, 수신자는 실제 전송된 내용을 그대로 검증합니다. 수신자는 다음 순서로 확인하세요.
X-Continuum-Timestamp를 읽고 허용 범위보다 오래된 값은 거부합니다.- 공유 시크릿으로
"{timestamp}.{raw_body}"에 대한 HMAC-SHA256을 다시 계산합니다. - 16진수 다이제스트를
sha256=값과 상수 시간으로 비교합니다.
import hashlib
import hmac
import time
def verify(secret: bytes, timestamp: str, raw_body: bytes, header: str, tolerance: int) -> bool:
if abs(int(time.time()) - int(timestamp)) > tolerance:
return False
expected = "sha256=" + hmac.new(
secret, timestamp.encode() + b"." + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, header)
signing_secret이 없으면 타임스탬프와 서명 헤더를 보내지 않으며, 페이로드는 위의 범위 제한 엔벨로프 그대로입니다.
보안¶
대상은 설정 로드 시 검증하고, 재로드를 포함해 적용 시점에 다시 검증합니다.
url은https를 사용해야 하며 자격 증명을 포함할 수 없습니다.- 엄격 검증기는 루프백, 사설, 링크 로컬, 클라우드 메타데이터, 그 밖의 비공개 주소를 거부합니다. 확인되지 않는 호스트 이름은 설정 오류입니다.
allow_private_network: true는 클러스터 내부 수신자를 위해 주소 차단 목록을 해제합니다. 스킴과 형식 검사는 그대로 적용됩니다.- 검증 시 확인한 주소는 연결에 고정되어 확인과 연결 사이의 간격을 없앱니다. 리다이렉트는 비활성화되고, 각 시도에는 타임아웃이 있습니다.
- 서명 시크릿은 자격 증명입니다. 설정 내보내기와 비교에서 마스킹되고 로그에 남지 않으며,
${ENV_VAR}로 작성할 수 있습니다. 대상 URL도 경로에 수신자 토큰이 들어갈 수 있으므로 마스킹됩니다.
메트릭¶
| 메트릭 | 레이블 | 의미 |
|---|---|---|
notification_deliveries_total | event, target, result | 전송 시도마다 한 행입니다. result는 delivered 또는 failed입니다. |
notification_retries_total | event, target | 첫 시도 이후의 시도입니다. |
notification_queue_depth | target | 대상별 현재 대기 중인 이벤트 수입니다. |
notification_dropped_total | target | 대상 대기열이 가득 차 버린 이벤트 수입니다. |
notification_coalesced_total | event | 쿨다운으로 억제된 발생 수입니다. |
레이블은 설정으로 범위가 제한됩니다. 이벤트 이름은 고정 집합이고, target은 검증된 이름, 없으면 URL 호스트, 그것도 없으면 default입니다.
제외 범위¶
- Admin API나 허브를 통한 키별 엔드포인트 자기 등록은 제공하지 않습니다. 대상은 정적 설정 파일에서 오며, 시작 시 또는 핫 리로드 시 적용됩니다.
- 페이로드는 허브 사용량 레코드 스키마를 공유하지 않습니다. 별도의 더 가벼운 엔벨로프입니다.
- 로컬
budgets:범위 엔진에는 저수위 경고가 없습니다.quota_low는 허브에서 동기화된 월 예산에만 발생합니다. 윈도마다 저수위 지점을 한 번 계산하는 곳이 거기뿐이기 때문입니다. - 백엔드와 제공자의 429 및 5xx 응답은 알림 이벤트가 아닙니다.