feat(api): give the full build queue, auth failures and the overload 503 a stable code - #1023
Conversation
yeongseon
left a comment
There was a problem hiding this comment.
리뷰 (코멘트 — #1022 위에 쌓인 PR 이라 승인은 base 가 main 으로 바뀌고 체크가 녹색인 뒤에).
diff 를 읽었습니다. error 문장을 그대로 두고 code 만 더한 것, token_expired 만 따로 둔 이유(클라이언트가 할 일이 다르다) 모두 타당합니다.
확인해 주면 좋은 것 두 가지 (막는 것은 아님):
Unauthorized응답 예시의error가unauthorized→invalid api key로 바뀌었습니다. 이 예시는 키가 누락된 경우도 대표하는데, 그때 서버가 실제로 내는 문장이 같은지 본문에 근거가 없습니다. fixture 가 예시에서 생성되므로 실제 응답과 다르면 fixture 가 사실과 어긋납니다.token_expired판정이reason.endswith("ExpiredSignatureError")문자열에 묶여 있습니다. 테스트가 고정하고 있어 지금은 괜찮지만,reason형식을 바꾸면 조용히unauthorized로 내려갑니다 —AuthError를 만드는 자리에서 종류를 넘기는 쪽이 더 단단합니다.
로컬에서 테스트를 돌리지는 않았습니다.
|
머지 순서 주의. 이 PR 이 추가하는 에러 예시와 |
The fixture released the first service's worker and returned. The worker then ran its build behind the following tests, and test_json_array_reader measures peak memory with tracemalloc, which counts every thread. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…000-stable-error-codes
The code was read back from the end of the reason sentence, so rewording the reason would have turned token_expired into unauthorized without a failure anywhere. AuthError now carries the fact. Also pin that a missing API key and a wrong one both answer with the contract example's sentence. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
리뷰 두 가지 반영했습니다.
CI 실패는 base 인 #1022 의 테스트 fixture 때문이었고(해제한 워커 스레드를 기다리지 않아 다음 테스트의 로컬 실행: |
…pted-run-status # Conflicts: # CHANGELOG.md
…000-stable-error-codes # Conflicts: # tests/unit/test_oidc_auth.py
…e-error-codes # Conflicts: # contract/builder-api.yaml # contract/fixtures/responses.json # src/kpubdata_builder/service/app.py
…03, and the request id (#1024) Refs #994 > **#1023(#1000) 위에 쌓은 PR 이다** (그 PR 은 #1022 위에 있다). base 를 그 브랜치로 잡아 이 PR 의 diff 만 보이게 했다. 과부하 503 의 `code` 가 #1023 에서 생기기 때문이다. ## 이 PR 이 하는 것 — #1009 가 "이 PR 에 없는 것"으로 남긴 세 항목 중 둘 1. **`X-Provider-Key` 를 header 파라미터로 선언** — `components.parameters.ProviderKey`. 프로바이더를 호출하는 다섯 operation(`previewBuild`, `createBuild`, `submitBuild`, `getProviderStatus`, `testProviderConnection`)에 참조를 달았다. Studio 가 이 헤더를 보내는 호출과 같은 다섯이다. 2. **429 `auth_throttled`, 과부하 503, `X-Request-ID`** — 어느 operation 에서나 나올 수 있으므로 `SignupNotApproved` 와 같은 방식(`x-status` 를 가진 공용 응답)으로 선언했다: `components.responses.AuthThrottled`, `ServerOverloaded`, 그리고 `components.headers.RequestId`. 계약 1.79.0 → **1.80.0**. wire 는 바뀌지 않는다(선언만). response fixture 를 생성기로 다시 만들었다. ## 하지 않은 것 3. **`_DISPATCH_ROUTES` 를 라우트 어댑터에서 생성** — #1009 본문이 적은 대로 라우트가 if 연쇄라 표로 바꾸는 리팩터링이 먼저다. 이 PR 범위에 넣지 않았다. 그래서 `Refs`. `X-Request-ID` 는 헤더 컴포넌트로만 선언했고 각 operation 의 응답마다 참조를 달지는 않았다 — 모든 응답(수백 개)에 다는 것은 계약을 읽기 어렵게 만든다고 판단했다. 설명에 "핸들러가 쓰는 모든 응답에 실린다"고 적었다. ## 검증 — 선언과 실제를 대조한다 `tests/unit/test_contract_shared_declarations.py`: - 파라미터의 이름이 코드의 `PROVIDER_KEY_HEADER` 와 같고, 참조한 operation 집합이 정확히 위 다섯이다 - `_overloaded_response()` 의 상태·본문·헤더가 `ServerOverloaded` 선언과 같다 - 인증 실패 한도를 넘긴 실제 응답의 상태·키·`code` 가 `AuthThrottled` 선언과 같다 - 실제 HTTP 응답에 `X-Request-ID` 가 있다 계약 관련 테스트 파일: `424 passed`. `check_contract_compat.py`: `origin/main` 대비 `1.77.0 -> 1.80.0` 호환. ruff, mypy 통과. 전체 스위트는 CI 가 돌린다. ## #1009 와의 관계 #1009 는 지금 `main` 과 충돌 상태다 — 내가 #1010 을 머지하면서 `main` 이 1.77.0 이 됐고, #1009 도 1.77.0 을 쓴다. 이 스택(#1022 → #1023 → 이 PR)은 1.78.0 ~ 1.80.0 을 쓴다. 어느 쪽이 먼저 들어가든 나중 쪽이 버전 줄을 다시 맞춰야 하니, 순서를 정해 주면 내 쪽을 그에 맞추겠다. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Eomdahyeon <213566566+Eomdahyeon@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ot count expired tokens (#1035) Closes #1031 ## 문제 인증 실패 스로틀은 클라이언트를 TCP peer 주소로 가린다. compose 가 싣는 리버스 프록시(Caddy) 뒤에서는 모든 사용자가 프록시의 주소로 도착해 한 버킷을 공유한다. 거기에 만료된 토큰도 401 이라 실패로 셌다. 그래서 사용자 몇 명의 평범한 토큰 만료만으로 한도에 닿고, 윈도가 지날 때까지 모두가 429 `auth_throttled` 를 받는다. ## 변경 **1. 만료된 토큰은 세지 않는다.** `app.py` 의 게이트가 `principal.status_code == 401 and not principal.expired` 일 때만 실패를 기록한다. `expired` 는 #1023 에서 `AuthError` 가 만들어지는 자리(`jwt.ExpiredSignatureError`)에서 세운 플래그다 — 문장을 읽지 않는다. 만료 토큰은 실패 기록을 **지우지도** 않는다(성공이 아니므로). **2. 이름을 댄 프록시 뒤에서만 `X-Forwarded-For` 를 읽는다.** `KPUBDATA_BUILDER_TRUSTED_PROXIES` (주소·CIDR 블록, 콤마 구분, 기본 미설정). - TCP peer 가 그중 하나일 때에만 헤더를 읽는다. 아니면 peer 가 식별자다 — Builder 에 직접 닿은 요청은 헤더로 자기를 다르게 댈 수 없다. - 오른쪽부터 읽어 신뢰하는 프록시가 아닌 첫 주소를 클라이언트로 본다. 프록시는 자기가 본 주소를 **덧붙이므로**, 클라이언트가 직접 써 보낸 값은 그 왼쪽에 있고 닿지 않는다. - 헤더가 없거나, 전부 신뢰하는 프록시이거나, 읽다가 주소가 아닌 값을 만나면 peer 로 돌아간다. - 주소로 읽히지 않는 설정 항목은 경고 로그와 함께 버린다(기동은 실패하지 않는다; 남는 설정은 덜 신뢰하는 쪽이다). IPv4-mapped IPv6 peer(`::ffff:a.b.c.d`)는 IPv4 로 맞춘다. - 식별 로직은 `auth_throttle.py` 의 `client_identity` 한 곳에 있고 `http.py` 는 그것을 부른다. **3. 문서와 배선.** `docs/deploy.md` 에 "리버스 프록시 뒤에서" 절, `docs/deployment.md` 표, `.env.app.example`. `docker-compose.prod.app.yml` 은 변수를 **값 없이 통과만** 시킨다. ## 운영 설정에 대해 — 정해 주셔야 하는 것 - **compose 에 기본값을 넣지 않았다.** 설정하지 않으면 동작은 전과 같다(이슈의 세 번째 조건). 그래서 이 PR 만으로는 문서화된 기본 배포의 문제가 풀리지 않는다 — 누군가 값을 적어야 한다. 적을 값은 Caddy 컨테이너가 붙은 Docker 네트워크의 대역이고 배포마다 다를 수 있어 제가 고르지 않았다. - **Cloudflare 가 앞에 있으면 Caddy 설정도 필요하다.** `.env.app.example` 은 "Cloudflare proxied DNS → Caddy" 구성을 말한다. Caddy 는 기본적으로 앞단을 신뢰하지 않고 `X-Forwarded-For` 를 자기가 본 peer(Cloudflare 주소)로 쓴다. 그 상태에서는 Builder 가 사용자 대신 Cloudflare 엣지 주소로 묶는다. `ops/caddy/Caddyfile` 에는 `trusted_proxies` 가 없다. 이것은 문서에 적어 두기만 했고 Caddyfile 은 건드리지 않았다. (Caddy 의 이 기본 동작은 제 지식에 따른 것이고 이 저장소에서 프록시를 띄워 확인한 것은 아니다.) ## 알아 둘 것 만료된(서명이 유효했던) 토큰 하나를 가진 쪽은 이제 그것을 스로틀 없이 반복해 보낼 수 있고, 그때마다 서명 검증 한 번의 CPU 가 든다. 모듈 docstring 이 스로틀의 두 번째 목적으로 든 바로 그 비용이다. 이슈의 결정(만료는 세지 않는다)을 따랐고, 위조·무효 토큰은 여전히 센다. ## 테스트 `tests/unit/test_auth_throttle_proxy.py` (새 파일): - `client_identity`: 설정 없음 / peer 가 프록시가 아님 / 프록시가 알려 준 주소 / 클라이언트가 써 보낸 접두 무시 / 신뢰하는 프록시 여러 겹 / 쓸 수 없는 헤더 네 가지 / IPv4-mapped peer / peer 없음. - 설정 파싱과 환경변수 읽기. - **실제 소켓**(테스트 클라이언트가 "프록시", peer `127.0.0.1`): 한 프록시 뒤 두 클라이언트가 따로 스로틀됨 / 헤더 왼쪽을 바꿔도 새 버킷을 얻지 못함 / **설정이 없으면 헤더가 아무것도 바꾸지 않음**. - 만료 토큰 10번에도 카운트가 움직이지 않음 / 서명이 틀린 토큰은 여전히 4번째에 429 / 만료 토큰이 섞여도 실제 실패 3번이 한도에 닿음. `test_prod_oidc_plumbing.py` 의 compose 통과 목록에 새 변수를 더했다. ## 검증 - `pytest tests/unit/test_auth_throttle_proxy.py tests/unit/test_auth_throttle.py tests/unit/test_oidc_auth.py tests/unit/test_stable_error_codes.py` → `117 passed`. - `pytest tests/unit -k "doc or env or compose or deploy"` → `110 passed`. - `ruff check src tests`, `ruff format --check src tests`, `mypy src` 통과. `mkdocs build --strict` 통과. - 전체 스위트는 로컬에서 돌리지 않았다 — CI 에 맡긴다. **실제 프록시 뒤에서 돌려 보지 않았다.** 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Eomdahyeon <213566566+Eomdahyeon@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Closes #1000
문제
일부 에러 본문에
code가 없어서 클라이언트가 문장으로 분기해야 한다. 특히POST /builds의 큐 초과 429 는auth_throttled429 와 문장으로만 구분됐다.변경 내용 — 이슈의 네 가지
codebuild_queue_fullunauthorized, 토큰의exp가 지났으면token_expiredauth_unavailableserver_overloadedcredentials_required— #1022 에서error문장은 그대로 둔다.code만 추가된다.{"error": <AuthError.reason>}이었고code가 없었다.AuthError.code프로퍼티가 상태와 사유에서 코드를 정한다.token_expired를 따로 둔 이유: 클라이언트가 할 일이 다르다(토큰을 새로 받아 재시도). 나머지 401 은unauthorized하나다 — 어떤 검증이 실패했는지까지 코드로 나누지 않았다.Error.code설명에 코드 목록,Unauthorized응답의 예시와 설명,submitBuild429 예시.Error스키마에는 이미 선택적code가 있어 스키마 구조는 바뀌지 않는다.검증
tests/unit/test_stable_error_codes.py(큐 초과 본문, 과부하 본문),test_oidc_auth.py::TestStableAuthCodes(거부·JWKS 불가·만료 토큰이 실제 응답 본문에 싣는 코드).721 passed. 전체 스위트는 돌리지 않았다 — CI 가 확인한다.check_contract_compat.py:origin/main대비1.77.0 -> 1.79.0호환. ruff, mypy 통과.겹치는 것
#1009 가 계약의 같은 영역(공용 응답, 버전 줄)을 고친다. 나중에 머지되는 쪽에서 버전 줄을 맞춰야 한다.
🤖 Generated with Claude Code