Repository navigation
fix(python): sync 응답의 기한을 소켓의 원문 읽기마다 — 헤더·청크 틀·트레일러·TLS 레코드까지, 응답마다 바이트 예산 (response-read-deadline-divergent · response-framing-unbounded) - #893
Merged
Conversation
xzawed
force-pushed
the
py-socket-deadline
branch
from
October 29, 2026 08:14
12acf61 to
9382823
Compare
`tls_cert` 픽스처와 서버 문맥(`_server_tls` → `server_tls`)이 test_no_automatic_retry.py 안에 있어, 다른 시험 모듈이 쓰려면 픽스처를 모듈 사이로 import 해야 했다(ruff F811 · 픽스처 이름이 별칭을 따른다). 다른 공유 도우미처럼 conftest 에 두고 `from tests.unit.conftest import server_tls` 로 쓴다. 인증서를 만드는 코드는 그대로이고 범위만 module → session(한 실행에 한 번)이다. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R4jYS5GwFA8V5oAqjw8pMp
… 응답마다 본문 상한 + 틀 몫 65,536 의 바이트 예산 (response-read-deadline-divergent · response-framing-unbounded F3a) #889 의 마감은 SDK 의 본문 고리(read1 앞뒤)에 있어 http.client 호출 하나 안의 읽기를 못 봤다 — 헤더 블록(`send` 안)·청크 크기 줄·트레일러를 흘리는 서버 앞에서 그 호출이 끝난 뒤에야 실패했다. 그리고 http.client 는 줄 하나를 65,536 에서 끊지만 트레일러 줄 수는 세지 않아 4 KiB 줄 4,096 개(16 MiB)를 끝까지 읽고 받아들였다. 자리(`_internal/wire.py`): 응답의 읽기는 전부 http.client 응답 버퍼 아래 원문 `readinto` 한 자리를 지난다. SDK 세션의 HTTPAdapter 마다 풀 관리자의 `connection_from_pool_key`(프록시면 `proxy_manager_for` 가 내준 관리자의 것) → 풀의 `_get_conn` → 연결의 `response_class` → 응답 버퍼의 `raw.readinto` 로 닿는다(전역 패치 없음). 요청 직전에 `exchange` 가 마감·예산을 문맥 변수에 걸고, 원문 읽기마다 앞뒤로 마감을 보고 읽기 하나를 `min(읽기 타임아웃, 남은 시간)` 으로 묶으며(끝나면 되돌린다) 받은 바이트를 예산에 올린다(읽기 하나는 예산 + 1 까지만 청한다). 거는 곳: auth 세션은 생성 때 (`bound_reads` — sync·aio 의 쓰지 않는 sync 세션 둘 다, `forbid_retries` 옆), admin 의 두 sync 세션은 요청마다(`admin_guard._plan_reads`). 실패는 지금과 같은 꼴 — 마감은 소켓 타임아웃의 `TimeoutError` (urllib3 ReadTimeoutError), 예산은 http.client 틀 한도와 같은 `HTTPException`(urllib3 ProtocolError) → 공개 API 에서 `KeycloakTransportError`, 본문 없음. #889 의 본문 고리 검사는 같은 마감으로 남긴다(버퍼에 와 있던 바이트 · HTTPAdapter 가 아닌 어댑터). 예산 = 본문 상한 + 틀 몫 65,536(ruby `MAX_FRAMING_BYTES` · .NET 헤더+트레일러 · Go 머리 블록과 같은 값): 토큰·introspection·logout·admin 자체 그랜트·admin 오류 응답 1,114,112 · JWKS 116,736. admin 성공 본문은 상태를 본 뒤 예산을 푼다(목록은 정당하게 크다 — 틀만 따로 세어도 같은 바이트를 본문으로 보내면 그만이다). 실측(Windows · Python 3.13.11 · requests 2.34.2 · urllib3 2.7.0 · 읽기 타임아웃 1 초 · 꼬리 10 B 를 0.7 초마다): - 수정 전(22af932): 틀 흘리기 sync 열여섯 칸 7,066~7,129 ms 뒤에야 실패 · TLS 레코드마다 한 바이트 7,105~7,129 ms · 레코드 하나 안의 흘리기는 이미 1,017~1,025 ms(CPython `_ssl` 읽기는 소켓 타임아웃을 호출 하나의 총량으로 쓴다) · F3a sync 다섯 레인 16,777,357~16,777,724 B 를 다 읽고 수락(admin 오류는 404). - 수정 뒤: 흘리기 전부 1,001~1,030 ms `KeycloakTransportError`(본문 흘리기도 1.4 s → 1.0 s) · F3a 는 예산 + 1 B(1,114,113 · JWKS 116,737)에서 거부 · HTTP 프록시 머리 흘리기 1,002 ms · CONNECT 응답 흘리기 1,007 ms. - 대조군: 멈춤 1,006~1,021 ms(그대로) · 상한 바로 아래 건강 응답 467~522 ms 수락(길이·4 KiB 청크 + 트레일러) · 2,408,890 B admin 목록 507 ms 수락 · admin 성공 본문 + F3a 는 16,777,482 B 를 읽고 수락(정책) · aio 는 그대로(머리 흘리기 1,004~1,019 ms · F3a 131,211~131,578 B 에서 h11 이 거부). 스위트(같은 소스 · #889 의 `reader` 뒤 검사 단위 시험을 test_wire.py 로 되살리기 전): 1,528 passed · 31 deselected · 커버리지 100.00%(1,449 문 · 270 가지 — wire.py 132 · 30) · ruff · ruff format · mypy strict 깨끗. 바탕(22af932 의 시험)은 1,460 passed.⚠️ 동작 변화(소비자에게 보인다 — CHANGELOG 는 PM): sync 응답 하나가 소켓에서 예산을 넘으면 `KeycloakTransportError` 다(16 MiB 트레일러 토큰 응답 · 압축 스트림 뒤 꼬리가 예산을 넘는 토큰 응답은 예전에 수락, 틀이 예산을 넘는 admin 오류는 예전에 `KeycloakNotFoundError` 등). 흘리는 머리·틀은 이제 마감에 실패한다. JWKS 메시지에는 `HTTP response exceeds 116736 bytes on the wire` 가 실린다. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R4jYS5GwFA8V5oAqjw8pMp
…없이 다시 읽는 길 (response-read-deadline-divergent) 등록부 남은 칸의 「gzip 성공 본문이 빈 값으로 풀리는 동안 urllib3 가 계속 읽는 길도 같은 부류(미측정)」. admin 성공 본문은 urllib3 가 푼다(`read1(decode_content=True)`) — 풀린 것이 없으면 그 호출 안에서 소켓을 다시 읽어, 본문 고리의 검사는 그 호출이 끝난 뒤에야 본다. gzip 본문의 마지막 10 바이트(deflate 끝 + CRC·길이)를 0.7 초마다. 실측(Windows · Python 3.13.11 · urllib3 2.7.0 · 읽기 타임아웃 1 초): 수정 전(22af932 의 src) 7,117 ms 뒤에야 `KeycloakTransportError`(시험 실패 — 한계 3 s) · 수정 뒤(59d44e15) 1,015 ms · 1,011 ms(두 번). 이 칸도 원문 읽기마다 보는 마감(`_internal/wire.py`)이 닫는다 — 소스 변경 없음. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R4jYS5GwFA8V5oAqjw8pMp
…록부 두 칸을 닫음 - CHANGELOG [Unreleased]: #889 의 Security 줄 끝 「남은 칸: python sync 와 JVM admin」에서 python 을 뺐다(이 PR 로 거짓이 된다 — 출시 전이라 고친다). python 줄 둘을 더했다 — Security(상태 줄·헤더·청크·트레일러·TLS 레코드 7 → 1.0 초) · Changed(⚠️ 응답마다 바이트 예산을 넘으면 KeycloakTransportError — 지금까지 받던 경우 셋). - 등록부 `response-read-deadline-divergent`: python sync 칸 닫힘 · 남은 python 칸(HTTPS 프록시 — TLS 안의 TLS, 코드 판독)을 적었다. `response-framing-unbounded`: python 트레일러 칸을 바이트 예산이 닫았다 — 남은 것은 node(상류 보고, 사람 몫). 적재 174781 이하 · 원본 199954 앵커 아래 46B · 모양 28/0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R4jYS5GwFA8V5oAqjw8pMp
…사슬 전부를 싣는다 - test_wire `test_urllib3s_own_chunk_parser_is_bounded_by_the_wire_budget[False]`: 대조군이 「훅 없이는 urllib3 자체 청크 파서가 16 MiB 크기 줄을 끝까지 읽는다」(CVE-2026-97689 의 취약 동작)를 단언해, 그것을 고친 urllib3 2.8.0(CI 가 푸는 판) 에서 실패했다(65,587 B 에서 urllib3 의 ProtocolError). 2.8.0 이상에서는 「예산이 아니라 urllib3 가 끊었다」(예산보다 적게 읽고 ProtocolError)를 본다 — 판정은 어느 판이든 hooked 칸이다. 2.7.0(로컬 venv)·2.8.0(따로 받은 판) 둘 다 통과. - test_response_framing_budget: admin 성공 대조군이 CI 에서만 「Can't connect to server」(5 ms · 419 KB)로 실패했다 — Windows·WSL Ubuntu(urllib3 2.8.0 · requests 2.34.2 · 커버리지 포함·전체 스위트)에서는 재현되지 않는다. python-keycloak 은 get 에서 난 예외를 무엇이든 그 문구로 감싸므로 단언 메시지가 원인 사슬 전부를 싣게 했다(진단 — 다음 CI 에서 읽는다). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R4jYS5GwFA8V5oAqjw8pMp
SDK 경계(lower.py)가 하위 사슬을 일부러 떼어 지난 CI 의 진단이 LowerLibraryError 에서 멈췄다 (KeycloakConnectionError at keycloak.connection.raw_get:346). raw_get 을 엿봐 원래 예외를 메시지에 붙인다. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R4jYS5GwFA8V5oAqjw8pMp
… 인지 본다 CI 의 진단(원래 예외)이 원인을 말했다: `http.client.HTTPException: got more than 100 trailers`. CI 의 CPython (3.10.22 · 3.11.17 · 3.12.15 · 3.13.17 · 3.14.9)은 트레일러 줄 수를 100 에서 묶는 보안 패치 판이고, 로컬 3.13.11 · WSL 3.12.3 은 묶지 않는다. 그래서 「admin 성공 본문은 16 MiB 트레일러도 끝까지 받는다」는 대조가 새 판에서 거짓이었다. - 시험 모듈이 판 번호가 아니라 동작으로 묻는다(`HTTP_CLIENT_LIMITS_TRAILERS` — http.client 에 트레일러 101 개를 먹여 본다). 묶는 판에서는 「끊은 것이 http.client 다(그 문구 · SDK 예산 문구 아님 · 예산보다 적게 읽음)」를 본다. 큰 목록을 끝까지 받는 대조는 `test_a_large_admin_list_is_read_whole` 이 진다. - 검증: 실제 3.13.11 → 묶지 않는 갈래 통과(16,777,482 B) · 새 판의 http.client 동작을 흉내 낸 임시 플러그인 → 묶는 갈래 통과(418,056 B — CI 의 419,080 B 와 같은 꼴) · 모듈 전체도 흉내 아래 통과. - wire.py 머리말 · CHANGELOG 의 「트레일러 줄 수를 세지 않는다 · 16 MiB 를 받았다」에 「판에 따라」를 붙였다. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R4jYS5GwFA8V5oAqjw8pMp
xzawed
force-pushed
the
py-socket-deadline
branch
from
October 29, 2026 08:29
fa634fe to
7b2009c
Compare
|
xzawed
added a commit
that referenced
this pull request
Oct 29, 2026
…I 하위 판 함정 - 진입점 1번: 판정 수행이 끝났다 — 남은 칸은 각 항목이 적는다(응답 기한 · 번역 가드 · php (6) · framing node 상류 보고). - 지금 상태: `[Unreleased]` 에 아홉 언어 다 변화가 있다 — 다음 물결은 사람 몫. - 함정 3번: CI 의 하위 판(urllib3 2.8 · CPython 트레일러 상한)이 대조군을 뒤집었다(#893) — 대조군은 동작으로 가른다 · 충돌한 PR 에는 실행이 안 생긴다. 옛 예시 셋(jwks 600 초 경계 · node·.NET · probe.sh 정규식)은 git 이력이 소유한다. - 판정 문단: 라운드 2(#892) 포인터. 적재 174781 이하 · 원본 199954 앵커 아래 48B · 모양 28/0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R4jYS5GwFA8V5oAqjw8pMp
xzawed
added a commit
that referenced
this pull request
Oct 29, 2026
…I 하위 판 함정 (#900) - 진입점 1번: 판정 수행이 끝났다 — 남은 칸은 각 항목이 적는다(응답 기한 · 번역 가드 · php (6) · framing node 상류 보고). - 지금 상태: `[Unreleased]` 에 아홉 언어 다 변화가 있다 — 다음 물결은 사람 몫. - 함정 3번: CI 의 하위 판(urllib3 2.8 · CPython 트레일러 상한)이 대조군을 뒤집었다(#893) — 대조군은 동작으로 가른다 · 충돌한 PR 에는 실행이 안 생긴다. 옛 예시 셋(jwks 600 초 경계 · node·.NET · probe.sh 정규식)은 git 이력이 소유한다. - 판정 문단: 라운드 2(#892) 포인터. 적재 174781 이하 · 원본 199954 앵커 아래 48B · 모양 28/0. Claude-Session: https://claude.ai/code/session_01R4jYS5GwFA8V5oAqjw8pMp Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



무엇
#889(응답 기한)가 남긴 python sync 칸과
response-framing-unbounded의 python F3a 칸을 닫는다. 판정 2026-10-29 의 기제(읽기마다의 벽시계 검사 — 감시자·스레드·타이머 없음)를 본문 고리에서 소켓의 원문 읽기 하나하나로 내렸다(rubyReadDeadline· JVM authDeadlineSocket과 같은 자리). 같은 자리에 응답마다 바이트 예산(본문 상한 + 틀 몫 65,536)을 걸었다.어디에 거는가 —
python/src/keycloak_sdk/_internal/wire.pyreadinto한 자리를 지난다. 상태 줄 · 헤더 · 1xx · 청크 크기 줄 · 트레일러 · 본문 · 프록시 CONNECT 응답 · TLS 평문이 다 여기다.connection_from_pool_key(프록시면proxy_manager_for가 내준 관리자의 것)_get_connresponse_classraw.readintoauth.py·aio/auth.py생성 때bound_reads)과 admin 의 sync 세션 둘(요청마다admin_guard가 다시 본다)이다. 마감·예산을 거는 곳은token_cap.py·admin_body.py(성공 본문은 상태를 본 뒤 예산을 푼다) ·jwks_fetch.py다.min(읽기 타임아웃, 남은 시간)으로 두고,finally에서 되돌린다.KeycloakTransportError이고, 메시지에 응답 바이트를 싣지 않는다. 마감은TimeoutError→ urllib3 read timeout, 예산은HTTPException→ urllib3 protocol error 로 간다.KeycloakConfigError로 거부한다(retries.py와 같은 꼴). HTTPAdapter 가 아닌 어댑터(소비자가mount한 전송)는 건너뛴다. 그때도 fix(java,kotlin,python,ruby,dotnet): SDK 가 읽는 응답을 설정한 타임아웃 안에 전부 읽는다 — 흘리는 서버가 호출 하나를 ≈ 상한 × 읽기 타임아웃 붙잡던 것 (response-read-deadline-divergent 판정) #889 의 본문 고리 마감은 남는다(같은 시계).MAX_FRAMING_BYTES· .NET 헤더+트레일러 한도 · Go 머리 블록 한도와 같은 값이다. JVM 의 8× 배수는 8 MiB 트레일러를 통과시켜 쓰지 않았다. 종류별 값은 이렇다:RED → GREEN (읽기 1 s · 마지막 10 B 를 700 ms 마다 · 한계 3 s — 레그)
TLS 레코드 하나 안을 흘리는 칸은 RED 가 아니었다(1,017–1,025 ms). CPython
_ssl의 읽기는 소켓 타임아웃을 호출 하나의 총량으로 쓰기 때문이고, ruby·JVM 과 다르다. 가드로 남겼다.대조군:
변이(커밋 위에 걸고 되돌림):
게이트(레그)
전체 1,529 통과(바탕 1,460) · 31 선택 해제 · 커버리지 100.00%(문장 1,449 · 가지 270) · ruff · ruff format · mypy strict(41 파일) 깨끗 · 파사드 걷기 · 적대 경로 행렬 통과. aio 의 F3a 는 h11 이 131,211–131,578 B 에서 거부(바탕과 같다).
독립 검토(Grok —
wire.py한 파일, 닫힌 질문 넷)raw.readinto를 지나지 않고 응답 바이트를 읽는 길이 있는가 → 없다.BufferedReader.read/read1/readline/peek/readinto는raw.readinto로만 채우고,RawIOBase.read/readall은readinto를 부른다. 감싸는 때는makefile직후, 버퍼가 비어 있을 때다.finally).ContextVar에서 잡고, 쉬던 연결의 다음 응답은 다시 묻는다. 스레드는 문맥이 따로다.urllib3 권고(검색이 찾은 CVE-2026-97689 — 레그가 확인)
stream()/read_chunked()길이다. SDK 는read1()→ http.client 의 상한 있는 파서를 지나 16 MiB 청크 확장을 65,536 에서 거부한다. 2.7.0 에서 urllib3 자체 파서를 강제해도 이 훅이 1,114,113 B 에서 끊는다.CI 에서 드러난 판 차이 둘(PM — 대조군을 판이 아니라 동작으로 가름)
첫 CI 에서 python 다섯 판이 대조군 둘에 실패했다. 둘 다 SDK 결함이 아니라 하위 판이 이미 고친 것에 기댄 대조였다.
_MAX_CHUNK_LINE_LENGTH). 로컬 venv 는 2.7.0 이다.HTTPException: got more than 100 trailers). 로컬 3.13.11 · WSL 3.12.3 은 묶지 않는다.남은 칸(등록부에 적음)
urllib3/util/ssltransport.py:266) 안쪽 레코드 흘리기가 기한을 넘길 수 있다. 늦은 바이트는 거부한다 — 코드 판독이고 미측정이다.문서(PM)
[Unreleased]:KeycloakNotFoundError등으로 나왔다)이다.response-read-deadline-divergent의 python sync 칸 닫힘 ·response-framing-unbounded의 python 칸 닫힘 — node 만 남는다(상류 보고, 사람 몫). 원본 199954 앵커 아래 46B..claude/rules/python.md의 함정 한 줄(「버퍼 채우기마다 걸면 머리·틀 흘리기를 놓친다 — M2」)은 넣지 않았다. 변이 M2 를 시험이 잡고 코드 머리말이 그 자리에 있으며, python.md 는 예산 여유가 26B 다.🤖 Generated with Claude Code
https://claude.ai/code/session_01R4jYS5GwFA8V5oAqjw8pMp