Skip to content

feat(api): give the full build queue, auth failures and the overload 503 a stable code - #1023

Merged
Eomdahyeon merged 8 commits into
mainfrom
feat/issue-1000-stable-error-codes
Oct 4, 2026
Merged

Eomdahyeon merged 8 commits into
mainfrom
feat/issue-1000-stable-error-codes

Conversation

@Eomdahyeon

Copy link
Copy Markdown
Collaborator

Closes #1000

#1022(#996) 위에 쌓은 PR 이다. base 를 그 브랜치로 잡아 이 PR 의 diff 만 보이게 했다. #1022 가 머지되면 base 를 main 으로 바꾼다.

문제

일부 에러 본문에 code 가 없어서 클라이언트가 문장으로 분기해야 한다. 특히 POST /builds 의 큐 초과 429 는 auth_throttled 429 와 문장으로만 구분됐다.

변경 내용 — 이슈의 네 가지

경우 상태 code
async build 큐 초과 429 build_queue_full
인증 실패 401 unauthorized, 토큰의 exp 가 지났으면 token_expired
JWKS 를 가져오지 못함 503 auth_unavailable
과부하 (요청을 읽기 전 소켓 응답) 503 server_overloaded
재시작으로 중단된 run — credentials_required — #1022 에서
  • 본문의 error 문장은 그대로 둔다. code 만 추가된다.
  • 401 본문은 이슈가 "확인하지 않은 것"으로 남긴 부분이다. 확인: {"error": <AuthError.reason>} 이었고 code 가 없었다. AuthError.code 프로퍼티가 상태와 사유에서 코드를 정한다.
  • token_expired 를 따로 둔 이유: 클라이언트가 할 일이 다르다(토큰을 새로 받아 재시도). 나머지 401 은 unauthorized 하나다 — 어떤 검증이 실패했는지까지 코드로 나누지 않았다.
  • 계약 1.78.0 → 1.79.0 (additive): Error.code 설명에 코드 목록, Unauthorized 응답의 예시와 설명, submitBuild 429 예시. Error 스키마에는 이미 선택적 code 가 있어 스키마 구조는 바뀌지 않는다.

검증

  • tests/unit/test_stable_error_codes.py(큐 초과 본문, 과부하 본문), test_oidc_auth.py::TestStableAuthCodes(거부·JWKS 불가·만료 토큰이 실제 응답 본문에 싣는 코드).
  • 인증·서비스·계약 fixture·job 관련 테스트 파일: 721 passed. 전체 스위트는 돌리지 않았다 — CI 가 확인한다.
  • check_contract_compat.py: origin/main 대비 1.77.0 -> 1.79.0 호환. ruff, mypy 통과.

겹치는 것

#1009 가 계약의 같은 영역(공용 응답, 버전 줄)을 고친다. 나중에 머지되는 쪽에서 버전 줄을 맞춰야 한다.

🤖 Generated with Claude Code

Eomdahyeon and others added 2 commits October 4, 2026 23:59
Closes #996

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…503 a stable code

Closes #1000

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@yeongseon yeongseon left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

리뷰 (코멘트 — #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 를 만드는 자리에서 종류를 넘기는 쪽이 더 단단합니다.

로컬에서 테스트를 돌리지는 않았습니다.

@Eomdahyeon

Copy link
Copy Markdown
Collaborator Author

머지 순서 주의. 이 PR 이 추가하는 에러 예시와 code 는 Studio 의 드리프트 검사(ERROR_READERS)가 아직 모른다. Builder main 에 들어가면 Studio 의 모든 PR 에서 Builder contract drift 가 실패한다 — 이 스택의 계약으로 Studio 드리프트 테스트를 돌려 확인했다(7개 실패). kpubdata-lab/kpubdata-studio#743 이 그 항목들을 버전 조건과 함께 추가하므로, #743 을 먼저 머지한 뒤 이 PR 을 넣어야 한다. #743 은 지금 Builder main(1.77.0)에서도 통과한다.

Eomdahyeon and others added 3 commits October 5, 2026 00:17
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>
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>
@Eomdahyeon

Copy link
Copy Markdown
Collaborator Author

리뷰 두 가지 반영했습니다.

  • Unauthorized 예시의 문장: 키가 누락된 경우와 틀린 경우 모두 _verify_api_key 의 같은 마지막 줄(AuthError(reason="invalid api key"))로 떨어집니다 — api_key is None 이면 비교를 건너뛰고 그 줄로 갑니다. 그래서 예시 하나가 두 경우를 대표하는 것이 사실과 맞습니다. 근거를 본문이 아니라 테스트로 남겼습니다: test_a_missing_key_and_a_wrong_key_get_the_contract_example 가 None 과 "wrong-key" 양쪽에서 ("invalid api key", "unauthorized") 를 고정합니다. (서버에 키가 설정되지 않은 경우는 api key not configured 로 문장이 다르지만 코드는 같은 unauthorized 이고, 예시가 대표한다고 적은 경우는 아닙니다.)
  • token_expired 판정: AuthError 에 expired: bool 필드를 두고 jwt.decode 의 except 에서 isinstance(exc, jwt.ExpiredSignatureError) 로 넘깁니다. code 는 더 이상 reason 을 읽지 않습니다. test_the_code_does_not_read_the_sentence 가 문장을 바꿔도 코드가 유지되고, 문장이 같은 꼴로 끝나도 플래그 없이는 unauthorized 임을 확인합니다.

CI 실패는 base 인 #1022 의 테스트 fixture 때문이었고(해제한 워커 스레드를 기다리지 않아 다음 테스트의 tracemalloc 측정에 섞임) 그쪽에서 고쳐 이 브랜치에 합쳤습니다.

로컬 실행: pytest tests/unit/test_oidc_auth.py tests/unit/test_stable_error_codes.py tests/unit/test_response_fixtures.py tests/unit/test_interrupted_run_status.py tests/unit/test_json_array_reader.py → 395 passed, ruff check / ruff format --check / mypy src 통과. 전체 스위트는 CI 에 맡겼습니다.

…000-stable-error-codes

# Conflicts:
#	tests/unit/test_oidc_auth.py
@Eomdahyeon
Eomdahyeon changed the base branch from fix/issue-996-interrupted-run-status to main October 4, 2026 15:34
…e-error-codes

# Conflicts:
#	contract/builder-api.yaml
#	contract/fixtures/responses.json
#	src/kpubdata_builder/service/app.py
@Eomdahyeon
Eomdahyeon merged commit 60093e2 into main Oct 4, 2026
22 checks passed
Eomdahyeon added a commit that referenced this pull request Oct 4, 2026
…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>
Eomdahyeon added a commit that referenced this pull request Oct 5, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(api): give the queue-full 429 and auth failures a stable error code

2 participants