콘텐츠로 이동

속도 제한 및 할당량 웹훅 알림

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}")>

본문은 바이트로 한 번 직렬화하고 서명한 뒤 그대로 전송하므로, 수신자는 실제 전송된 내용을 그대로 검증합니다. 수신자는 다음 순서로 확인하세요.

  1. X-Continuum-Timestamp를 읽고 허용 범위보다 오래된 값은 거부합니다.
  2. 공유 시크릿으로 "{timestamp}.{raw_body}"에 대한 HMAC-SHA256을 다시 계산합니다.
  3. 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 응답은 알림 이벤트가 아닙니다.