Repository navigation
fix(java,kotlin,python,ruby,dotnet): SDK 가 읽는 응답을 설정한 타임아웃 안에 전부 읽는다 — 흘리는 서버가 호출 하나를 ≈ 상한 × 읽기 타임아웃 붙잡던 것 (response-read-deadline-divergent 판정) - #889
Merged
Conversation
…S 면 JSSE 아래 평문 소켓), admin 은 엔진 스트림의 읽기마다 마감을 본다 (response-read-deadline-divergent) 읽기 타임아웃(SO_TIMEOUT)은 읽기 하나만 묶어, 바이트를 그보다 조금 짧은 간격으로 흘리는 서버 앞에서 응답 하나가 흘린 바이트마다 간격 하나씩 걸렸다(최악 ≈ 상한 × 읽기 타임아웃). 수정 전 실측(2026-10-29 · 이 커밋의 시험을 수정 전 코드에 · Windows · Temurin 21.0.8 · Maven 3.9.9 · HttpClient 4.5.14 / HttpCore 4.4.16 · RESTEasy 6.2.15 · 루프백 · 읽기 타임아웃 1 초 · 마지막 10 바이트를 0.7 초마다): 토큰 7,066 ms · JWKS 콜드 적재 7,200 ms · admin 자체 토큰 부여 7,456 ms · admin 자원 본문 7,100 ms 뒤 모두 받아들였다. 틀을 흘리면(토큰 레인) 머리 블록 7,334 · 청크 크기 줄 7,069 · 트레일러 7,069 ms 뒤 수락, TLS 레코드 하나를 흘리면(TLS 앞 TCP 중계가 레코드의 바이트를 0.7 초마다) 33,994 ms 뒤 수락(RED 아홉). - auth·JWKS(BoundedTransport): 실행기가 요청을 보내기 직전(연결을 빌리고 맺은 뒤) 그 스레드에 마감을 건다(arm — ThreadLocal, 길이 = 교환의 읽기 타임아웃). 두 소켓 팩토리(평문 · HucTls)가 짓는 소켓이 입력 스트림의 읽기 앞뒤로 그 마감을 본다(deadlined) — 마감 뒤에 읽기를 시작하지 않고, 마감 뒤에 끝난 읽기의 결과를 쓰지 않는다. 넘으면 읽기 타임아웃과 같은 SocketTimeoutException(「Read timed out: the response was not fully read within N ms of sending the request」 — 본문 없음)이고 공개 API 에서는 지금처럼 KeycloakTransportException · TokenValidationException 이다. 교환의 끝이 마감을 지운다(disarm).⚠️ 자리가 본문 스트림(wireBounded)이 아니라 소켓인 까닭: 본문 읽기 하나 안에 청크 크기 줄 · 트레일러 · TLS 레코드 하나를 기다리는 고리가 있고 머리는 본문 앞이다 — JSSE 는 레코드를 그 아래 평문 소켓의 스트림에서 읽는다(BaseSSLSocketImpl.getInputStream → self.getInputStream). 마감 예외는 JSSE 를 그대로 지난다(InterruptedIOException — SSLSocketImpl.readApplicationRecord· handleException 은 소켓을 닫지 않고 다시 던진다). 스레드·타이머 없음. 최악은 마감 + 읽기 타임아웃 하나다. - admin(AdminEngine): 교환의 손잡이(Exchange)가 그 교환의 HttpClientContext 가 되어, HttpCore 가 요청 머리를 쓰기 직전에 다는 http.request_sent = 거짓 표시에 보낸 때를 찍는다(연결을 빌리고 맺는 시간은 들지 않는다 · 참 표시는 옮기지 않는다). 엔티티 스트림(Settled)이 읽기 앞뒤로 그 마감을 본다 — 토큰 부여(가드의 상한 읽기) · 자원의 성공 본문(결합) · 오류 본문 · raw() 의 응답이 모두 그 스트림이다. 길이는 설정의 읽기 타임아웃(buildTimeoutClient 가 엔진에 넘긴다). 실패는 같은 레인의 읽기 타임아웃(멈춤)과 같은 메시지 「admin transport failure」 · 같은 원인 사슬(ResponseProcessingException → ProcessingException → SocketTimeoutException, 응답을 인용할 수 있는 사슬이라 WithheldCauses 가 타입 이름만 남긴다). GREEN(같은 시험, 네 번의 실행): 흘리기 토큰 1,416–1,420 · JWKS 1,410–1,504 · admin 토큰 부여 1,440–1,580 · admin 자원 1,434–1,669 ms 실패 · 틀 머리 1,416–1,455 · 청크 크기 줄 1,421–1,430 · 트레일러 1,421–1,427 ms · TLS 레코드 1,026–1,147 ms — 경계 3 초(마감 1 + 읽기 타임아웃 하나 1 + 여유 1; 여유는 다른 빌드가 함께 도는 기계의 깨움 지연 — 그래도 수정 전과 4 초 넘게 떨어진다). 대조: 멈춤(머리와 5 바이트 뒤 침묵) 토큰 1,011–1,012 · JWKS 1,009–1,011 · admin 1,017–1,038 ms 그대로(수정 전 1,021 · 1,014 · 1,018–1,043). 건강(상한 바로 아래 본문 네 조각 0.15 초 간격 — 토큰·admin 1,048,575 B · JWKS 51,199 B) 480–544 ms 수락 (수정 전 489–532). 남은 것(바꾸지 않음 — 실측, 저장소 밖 탐침 · 공개 API · 같은 조건): admin 은 엔진의 스트림이라 응답 머리와 읽기 하나 안의 틀 · TLS 레코드를 보지 못한다(그 소켓은 RESTEasy 가 지은 풀의 것 — 엔진을 새로 짓지 않는다는 결정 안에서는 싸지 않다). 머리 블록 · 청크 크기 줄 · 트레일러를 흘리면 7,527 · 7,089 · 7,102 ms, TLS 레코드는 33,643 ms 뒤에 KeycloakTransportException(수정 전에는 수락 — 이제 그 호출이 끝난 뒤의 첫 마감 검사가 실패시킨다). TLS 1.3 의 점잖은 닫기: admin 이 읽기 타임아웃 2 초에 멈춘 서버 앞에서 평문 2,039 ms · TLS 4,064 ms(EofSensorInputStream.checkAbort → 연결 닫기가 읽기 타임아웃만큼 한 번 더 읽는다 — 등록부의 2→4 초 그대로). 마감으로 끝나는 길은 그 닫기를 지나지 않는다 — admin TLS 흘리기(2 초 · 바이트마다 제 레코드) 2,853 ms. auth 는 실패가 연결을 닫기 전에 읽기 타임아웃을 0 으로 두어(quiet) TLS 멈춤 2,374 ms · TLS 흘리기 2,837 ms. 연결 수립(TCP 연결 · TLS 핸드셰이크)은 마감 밖이다 — 마감은 요청을 보낸 때부터다(핸드셰이크는 읽기마다의 타임아웃이 묶는다). 시험: keycloak-sdk-auth ResponseReadDeadlineTest(신규 12 — 레인 둘 × 흘리기·멈춤·건강 · 틀 셋 · TLS 레코드 · 마감 스트림 단위 · 교환이 스레드에 마감을 남기지 않음) · keycloak-sdk-admin ResponseReadDeadlineTest(신규 7 — 레인 둘 × 흘리기(멈춤과 같은 실패 모양) ·멈춤·건강 · 엔진 스트림 단위: 보낸 때부터 · 늦은 끝 -1). AdminResourceResponseCutTest 의 Exchange 생성자 호출을 새 서명으로. 뒤집은 단언 없음. 전체: mvn -f java/pom.xml verify -DskipITs=true → core 53 · auth 235(기준 223) · admin 119(기준 112) · keycloak-sdk 38(FacadeDump · HostilePathMatrix 포함) 실패 0 · 모듈별 커버리지 게이트 통과 — auth 분기 210/212(미커버 둘은 그대로 ValidatedToken.from · stripTrailingSlashes) · admin 96/98. 문서: .claude/rules/java.md 의 「202/204 (28 branches of slack)」 → 실측 210/212 (29) — 같은 길이. 함정(마감의 자리는 소켓이다 · admin 은 엔진 스트림이라 머리·틀·TLS 레코드를 못 본다)은 그 파일의 예산을 늘리지 않고 BoundedTransport 의 (9) · AdminEngine 의 설명이 소유한다. AdminResourceResponseCutTest 머리의 「멈춘 서버는 읽기마다의 타임아웃 몫(등록부 …)」 에 흘리는 서버는 마감 몫이라는 말을 더했다. README 에 거짓이 된 문장은 없다. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R4jYS5GwFA8V5oAqjw8pMp
…TLS 면 JSSE 아래 평문 소켓), admin 은 엔진 스트림의 읽기마다 마감을 본다 (response-read-deadline-divergent) Java(이 브랜치의 앞 커밋)와 같은 설계 · 같은 시험의 이식이다. 읽기 타임아웃(SO_TIMEOUT)은 읽기 하나만 묶어, 바이트를 그보다 조금 짧은 간격으로 흘리는 서버 앞에서 응답 하나가 흘린 바이트마다 간격 하나씩 걸렸다 — 코루틴 취소도 그 소켓 읽기는 끊지 못한다(onIo 의 runInterruptible — 플랫폼 스레드의 블로킹 읽기는 인터럽트를 무시한다). 수정 전 실측(2026-10-29 · 이 커밋의 시험을 수정 전 코드에 · Windows · Temurin 21.0.8 · Gradle 9.5.0(래퍼) · HttpClient 4.5.14 / HttpCore 4.4.16 · 루프백 · 읽기 타임아웃 1 초 · 마지막 10 바이트를 0.7 초마다): 토큰 7,941 ms(이 JVM 의 첫 호출) · JWKS 콜드 적재 7,171 · admin 자체 토큰 부여 7,102 · admin 자원 본문 7,083 ms 뒤 모두 받아들였다. 틀(토큰 레인): 머리 블록 7,071 · 청크 크기 줄 7,100 · 트레일러 7,083 ms 뒤 수락, TLS 레코드 하나를 흘리면 33,634 ms 뒤 수락(RED 여덟). - auth·JWKS(BoundedTransport): 실행기가 요청을 보내기 직전 그 스레드에 마감을 건다(arm — ThreadLocal, 길이 = 교환의 읽기 타임아웃 · 0 이면 없음). 두 소켓 팩토리가 짓는 DeadlineSocket 의 입력 스트림(DeadlinedStream)이 읽기 앞뒤로 그 마감을 본다 — 마감 뒤에 읽기를 시작하지 않고, 마감 뒤에 끝난 읽기의 결과를 쓰지 않는다. 넘으면 읽기 타임아웃과 같은 SocketTimeoutException(RESPONSE_LATE 「Read timed out: the response was not fully read within N ms of sending the request」 — 본문 없음, Java 와 같은 문장)이고 공개 API 에서는 지금처럼 KeycloakTransportException 「Auth request failed」 · TokenValidationException 「JWT validation failed」 이다. 교환의 끝이 지운다(disarm). 자리가 본문 스트림이 아니라 소켓인 까닭은 Java 커밋과 같다(본문 읽기 하나 안의 청크 크기 줄 · 트레일러 · TLS 레코드 · 본문 앞의 머리). 스레드·타이머 없음. - admin(AdminEngine): Exchange 가 그 교환의 HttpClientContext 가 되어 http.request_sent = 거짓 표시(HttpCore 가 요청 머리를 쓰기 직전)에 보낸 때를 찍고, Settled 가 읽기 앞뒤로 마감을 본다 — 토큰 부여 · 자원의 성공 본문 · 오류 본문 · raw() 의 응답. 길이는 설정의 읽기 타임아웃(BoundedEngineBuilder 가 엔진에 넘긴다). 실패는 같은 레인의 읽기 타임아웃(멈춤)과 같은 메시지 「Admin request failed」 · 같은 원인 사슬(RedactedCause 의 타입 이름 — 끝에 SocketTimeoutException). - 이름 있는 중첩 클래스 둘(DeadlineSocket · DeadlinedStream)은 FacadeDumpTest 의 면제 표에 이유와 함께 적었다(익명 객체는 kind=1 이라 선언으로 세어진다 — 그 표의 머리 설명 그대로). 둘 다 정적 풀의 연결 · 교환 하나에만 산다. GREEN(같은 시험, 두 번의 전체 실행 + 단독 실행): 흘리기 토큰 1,423 ms(첫 실행은 예열 없이 2,105 — 이 JVM 의 첫 호출만 요청을 보내기 전에 0.7–0.9 초를 더 쓴다, 그래서 레인마다 한 번 예열한다(warm) — 그 시간은 마감에 들지 않는다) · JWKS 1,433–1,552 · admin 토큰 부여 1,442–1,451 · admin 자원 1,444–1,445 ms 실패 · 틀 머리 1,408–1,417 · 청크 크기 줄 1,413–1,415 · 트레일러 1,422 ms · TLS 레코드 1,040–1,081 ms — 경계 3 초(마감 1 + 읽기 타임아웃 하나 1 + 여유 1, Java 와 같은 셈). 대조: 멈춤 토큰 1,006–1,013 · JWKS 1,026–1,038 · admin 1,014–1,244 ms 그대로(수정 전 1,072 · 1,019 · 1,018–1,235). 건강(상한 바로 아래 본문 네 조각 0.15 초 간격) 478–593 ms 수락(수정 전 477–543). 코루틴 취소(탐침, 저장소 밖 · 공개 API · 같은 흘리기): withTimeout(500) → 1,428 ms 에 TimeoutCancellationException(취소된 코루틴이 블로킹 읽기의 끝을 기다린다 — 이제 마감이 그 끝이다) · withTimeout(1500) → 1,427 ms 에 마감의 KeycloakTransportException. 등록부의 수정 전 실측은 withTimeout(1500) 이 7,044–7,072 ms 였다. admin 의 남은 자리(머리 · 읽기 하나 안의 틀 · TLS 레코드)와 TLS 1.3 의 점잖은 닫기는 Java 커밋에 잰 그대로다(같은 엔진) — 고치지 않았다. 시험: ResponseReadDeadlineTest(신규 12 — Java 와 같은 칸) · admin.ResponseReadDeadlineTest(신규 7). AdminResourceResponseCutTest 의 Exchange 생성자 호출을 새 서명으로. 뒤집은 단언 없음. 전체: ./gradlew cleanTest build test koverVerify ktlintCheck 통과 — test-results XML 합 370 tests(40 클래스) 0 실패. 기준 351(38 클래스 — 이 브랜치의 기준 코드에 같은 명령으로 잼; 그 기준 커밋 78d01240 의 메시지는 350 이라 적었다) + 19. Kover BRANCH 333/362(koverXmlReport — 기준 325/354, 미커버 29 그대로). 문서: .claude/rules/kotlin.md 의 「91.81% (325 of 354)」 · 「denominator is 354」 · 「Slack is now 24」 → 실측 91.99% (333 of 362) · 362 · 25 — 같은 길이(0.28 points 는 그대로 참). 함정(마감의 자리 · admin 이 못 보는 자리)은 BoundedTransport 의 (9) · AdminEngine 의 설명이 소유한다. AdminResourceResponseCutTest 머리에 흘리는 서버는 마감 몫이라는 말을 더했다. README 에 거짓이 된 문장은 없다. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R4jYS5GwFA8V5oAqjw8pMp
…ter(HttpClient.Timeout) 를 본문 읽기에 잇는다 (response-read-deadline-divergent) BoundedDocumentRetriever 는 ResponseHeadersRead 뒤에 본문을 읽어 HttpClient.Timeout 이 머리까지만 묶었고, 본문 읽기에는 기한이 없었다 — IdentityModel 이 설정을 CancellationToken.None 으로 묻고 SDK 의 강제 재조회도 None 이다. 수정 전 실측(2026-10-29 · 이 커밋의 시험을 수정 전 코드에 · Windows · .NET 8 테스트 호스트(SDK 10.0.102 로 빌드) · Microsoft.IdentityModel 8.23.0 · 원시 소켓 IdP · ReadTimeoutMs 1000 · 마지막 10 바이트를 0.7 초마다): 콜드 적재 — 디스커버리를 흘리면 7,046 ms · JWKS 를 흘리면 7,077 ms 뒤 검증 수락, 머리와 5 바이트 뒤 멈추면 서버가 닫을 때(10 초 붙든 뒤)에야 10,099 · 10,132 ms 에 IDX10500. 강제 재조회(따뜻한 캐시의 모르는 kid)의 JWKS 를 흘리면 그것을 기다린 검증이 7,073 ms 뒤 수락. 리트리버 직접: 흘린 본문을 7,058 ms 뒤 돌려줌(RED 여섯). - GetDocumentAsync 가 보내기 직전에 호출자 토큰을 이은 CTS 에 CancelAfter(_http.Timeout) 를 걸고(무한이면 걸지 않는다 — 그 클라이언트의 한도는 그 클라이언트의 것이다) 그 토큰을 본문 읽기에 넘긴다. 머리는 지금처럼 HttpClient.Timeout 이 묶는다(SendAsync 에는 호출자 토큰 그대로) — 본문이 같은 기한의 나머지를 쓴다. 기한이 다하면 HttpClient.Timeout 이 늦은 머리에 내는 것과 같은 모양 — TaskCanceledException 위에 TimeoutException(메시지에 본문 없음)이라 그것을 바꾸는 쪽이 둘을 같이 바꾼다. 공개 API 에서는 늦은 머리와 같은 실패다(같은 KeycloakTokenValidationException · 같은 IDX10500 메시지 — 시험이 둘을 대조한다). 호출자의 취소는 지금처럼 호출자의 것이다(수정 전과 같은 TaskCanceledException · 호출자의 토큰 · 아래에 타임아웃 없음). - .claude/rules/security.md 의 「찍은 재조회는 호출자가 무엇을 취소하든 SDK 자신의 타임아웃 아래에서 끝까지 간다」: 기한은 그 조회의 것이다 — 호출자 토큰이 그 안으로 이어질 뿐 거꾸로가 아니어서, None 으로 도는 강제 재조회는 SDK 의 타임아웃으로만 끝난다. 백오프의 셈은 그대로다 — 기한이 다한 조회는 실패로 센다(리트리버가 모든 실패를 센다 · KeycloakTransportException 이 아니라 콜드 게이트의 「백오프가 거절한 것」 판정에도 걸리지 않는다). JWKS 수명 브랜치의 취소 셈 시험(BackoffConfigurationManagerTests · BackoffConfigurationRetrieverTests · ValidationCancellationTests)은 손대지 않고 그대로 통과한다. GREEN(같은 시험): 흘리기 디스커버리 1,012 · JWKS 1,014 ms · 멈춤 1,049 · 1,132 ms · 강제 재조회 1,008 ms(IDX10503 — 캐시의 k1 이 답한다 · 재조회는 한 번) · 리트리버 1,014 ms TaskCanceledException(TimeoutException) — 기한이 취소라 정확하다. 경계 3 초(타임아웃 1 + 여유 2 — 다른 빌드가 함께 도는 기계; 수정 전과 4 초 넘게 떨어진다). 대조: 늦은 머리 1,005–1,013 ms 그대로(수정 전 1,005–1,013) · 상한 바로 아래(51,199 B) 문서 네 조각 0.15 초 간격 478–497 ms 수락(수정 전 474–478) · 호출자 취소 306 ms(수정 전 313) · 타임아웃 없는 클라이언트 467 ms(기한 없음, 수정 전 478). 시험: ResponseReadDeadlineTests(신규 12 — 레인 둘 × 흘리기(늦은 머리와 같은 실패)·멈춤·늦은 머리·건강 · 강제 재조회 · 리트리버의 예외 모양 · 호출자 취소 · 타임아웃 없음) + 원시 소켓 PacedIdp(keep-alive · 경로별 응답 횟수). 뒤집은 단언 없음. 전체: dotnet test --filter "Category!=Integration" 559 통과 · 0 실패(기준 547) · check-coverage.mjs 라인 97.95%(573/585) · 브랜치 94.38%(168/178, 여유 16 — 기준 557/569 · 164/174 · 16) · dotnet build -c Release 경고 0 · dotnet format --verify-no-changes 종료 0 · 통합(Category=Integration, 실 Keycloak 컨테이너) 10 통과 · 0 실패. 문서: BoundedDocumentRetriever 설명에 기한 한 문단 · JwtValidator 생성자의 http 매개변수(클라이언트의 Timeout 이 문서 하나를 본문까지 보낸 때부터 묶는다). .claude/rules/dotnet.md · README 에 거짓이 된 문장은 없다(dotnet.md 의 「HttpClient.Timeout 은 TaskCanceledException」 은 그대로 참이고 이 기한도 같은 모양이다). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R4jYS5GwFA8V5oAqjw8pMp
…), aio 는 교환 전체를 wait_for 로 (response-read-deadline-divergent) 읽기 타임아웃은 읽기 하나만 묶어, 바이트를 그보다 조금 짧은 간격으로 흘리는 서버 앞에서 응답 하나가 흘린 바이트마다 간격 하나씩 걸렸다. 수정 전 실측(2026-10-29 · 이 커밋의 시험을 수정 전 코드에 · Windows · Python 3.13.11 · python-keycloak 7.1.1 · requests 2.34.2 · urllib3 2.7.0 · httpx 0.28.1 · 읽기 타임아웃 1 초 · 마지막 10 바이트를 0.7 초마다): 토큰·JWKS·admin 이 sync·aio 모두 7,046~7,100 ms 뒤 성공(흘리기 여섯 칸 RED). - _internal/deadline.py(신규) — sync: `deadline(limit)` 벽시계 마감(monotonic, 요청을 보내기 직전부터)과 `reader(response, check)` — 와 있는 만큼(urllib3 `read1`) 읽고 읽기 앞뒤로 마감을 본다(마감 뒤에 시작하지 않고, 마감 뒤에 끝난 읽기의 바이트를 쓰지 않는다). `read(amt)` 는 amt 를 다 채울 때까지 기다려 읽기 하나가 바이트 수만큼의 간격을 다 기다렸다. aio: `within` — 교환 전체(연결·보내기·머리· 본문)를 asyncio.wait_for 로(하한 3.10 이라 asyncio.timeout 대신). 넘으면 requests.ReadTimeout · httpx.ReadTimeout 이라 공개 API 에서는 읽기 타임아웃과 같은 KeycloakTransportError 다(메시지에 본문 없음). 스레드·타이머 없음. 클래스가 아니라 클로저다 — 요청마다 만들고 버리는 값이라 클래스면 test_facade_dump·test_hostile_path_matrix 가 면제를 요구한다(첫 전체 실행에서 둘 다 그것으로 실패). - token_cap(auth 토큰·introspection·logout 과 admin 자체 그랜트) · admin_body(admin REST — 오류 본문과 성공 본문 둘 다; 성공 본문은 requests `content` 대신 같은 읽기 고리로, 하위 디코더 그대로) · jwks_fetch(sync·aio · 강제 재조회 포함 — aio 재조회 태스크 안에서도 SDK 자신의 타임아웃이다)가 쓴다. GREEN(같은 시험): 흘리기 sync 1,407~1,455 ms · aio 1,004~1,026 ms KeycloakTransportError — 경계는 3 초(마감 1 + 읽기 타임아웃 하나 1 + 여유 1; 여유는 다른 빌드가 함께 도는 기계의 깨움 지연, 그래도 수정 전과 4 초 넘게 떨어진다). 멈춤(대조 — 머리와 5 바이트 뒤 침묵) 1,008~1,043 ms 그대로(수정 전 1,007~1,027). 건강(대조 — 상한 바로 아래 본문 네 조각 0.15 초 간격) 472~509 ms 성공(수정 전 471~511). 시험: tests/unit/test_response_read_deadline.py(신규 28 — 레인 셋 × sync·aio × 흘리기·멈춤·건강 + 도우미 단위 10). test_token_response_cap 의 읽기 크기 시험은 SDK 의 읽기 자리를 따라 read1 도 엿본다 — 단언은 그대로(cap+1 을 넘게 청하지 않는다 · 받은 합이 cap+1). 뒤집은 단언 없음. 전체: 1262 passed · 31 deselected(기준 1234) · 커버리지 100% · ruff · ruff format · mypy strict 통과. 변이(저장소 밖 사본): read1 → read 면 sync 흘리기 셋이 7,069~7,074 ms 에야 실패(경계 밖) · 읽은 뒤 검사를 빼면 「마감 뒤에 끝난 읽기」 단위 시험이 실패 · wait_for 를 빼면 aio 흘리기 셋이 7,066~7,085 ms 뒤 성공. 남은 것(바꾸지 않음 — 실측, 공개 API · 같은 조건): sync 는 하위 호출 하나 안을 못 본다. 머리 블록 (send 안)·청크 크기 줄·트레일러를 흘리면 7,009~7,027 ms 에 KeycloakTransportError(수정 전은 셋 다 7,009~7,014 ms 에 성공) — 그 호출이 끝난 뒤에야 마감을 본다. aio 는 넷 다 1,005~1,011 ms. 문서: .claude/rules/python.md 는 예산 4690B 에 26B 남아(적재 4664B) 함정 한 줄(173B)을 넣지 못했다 — 모듈 docstring(_internal/deadline.py)이 소유한다. README 에 거짓이 된 문장은 없다. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R4jYS5GwFA8V5oAqjw8pMp
… (response-read-deadline-divergent) net-protocol 의 읽기 타임아웃은 기다림 하나만 묶어, 바이트를 그보다 조금 짧은 간격으로 흘리는 서버 앞에서 응답 하나가 흘린 바이트마다 간격 하나씩 걸렸다. 수정 전 실측(2026-10-29 · 이 커밋의 시험을 수정 전 코드에 · Windows · Ruby 3.4.10 · faraday 2.15.0 · faraday-net_http 3.5.0 · net-http 0.10.0 · net-protocol 0.4.0 · 읽기 타임아웃 1 초 · 마지막 10 바이트를 0.7 초마다): 토큰·JWKS·admin 이 7,069~7,128 ms 뒤 성공, TLS 본문 레코드의 바이트를 0.7 초마다 흘리면 22,929 ms 뒤 성공(RED 넷). - http.rb `ReadDeadline`(비공개 상수) — `on_connect` 가 연결의 IO(`@socket.io`, TCP 또는 TLS 소켓)에 extend 하고, `begin_transport` 가 요청마다 `read_timeout` 초 마감을 건다(보내기 직전). 그 IO 의 `read_nonblock` 이 읽기 시도마다 마감을 보고, 넘었으면 net-protocol 이 읽기 타임아웃에 내는 것과 같은 `Net::ReadTimeout`(to_io) 이다 → Faraday::TimeoutError(faraday-net_http 가 max_retries 0 이라 재시도 없음) → 경계의 TransportError(「… transport error: Net::ReadTimeout with #<TCPSocket:(closed)>」 — 읽기 타임아웃과 같은 타입·모양, 본문 없음). 상태 줄·헤더·틀·본문이 다 이 읽기로 오고 최악은 마감 + 읽기 타임아웃 하나다. 스레드·타이머 없음. 닫힌 소켓은 보지 않는다(끊은 뒤 읽기는 원판대로 IOError — `read_error_body` 가 그것만 삼킨다). -⚠️ `rbuf_fill` 입구가 아니라 `read_nonblock` 이다 — TLS 는 레코드가 다 올 때까지 :wait_readable 이라 채우기 하나 안에서 기다림이 이어진다. 변이(저장소 밖 사본, 마감 검사를 `rbuf_fill` 입구로 옮김): 평문 흘리기 셋은 1,432~1,477 ms 로 그대로 실패하는데 TLS 레코드 흘리기는 22,939 ms 뒤 성공 — TLS 칸이 이 자리를 고정한다. GREEN(같은 시험): 흘리기 auth·JWKS·admin 1,434~1,488 ms · TLS 레코드 흘리기 1,105 ms — Net::ReadTimeout (경계 3 초 = 마감 1 + 읽기 타임아웃 하나 1 + 여유 1). 멈춤(대조) 1,042~1,086 ms 그대로(수정 전 1,033~1,086). 건강(대조 — 상한 바로 아래 본문 네 조각 0.15 초 간격) 505~529 ms 성공(수정 전 500~543). 틀 실측(공개 API · 같은 조건, 저장소 밖 탐침): 머리 블록·청크 크기 줄·트레일러를 흘리면 수정 뒤 1,447~1,458 ms TransportError, 수정 전 7,067~7,087 ms 성공. 시험: spec/unit/response_read_deadline_spec.rb(신규 10 — 레인 셋 × 흘리기·멈춤·건강 + TLS) · spec/support/paced_server.rb(조각마다 쉬고 쓰는 서버 — 닫으면 쉬던 조각이 곧 깬다) · spec/support/trickle_relay.rb(TLS 앞 TCP 중계 — 표시 뒤 바이트를 하나씩) · http_framing_pins_spec 이 ReadDeadline 을 비공개로 고정한다. 뒤집은 단언 없음. 전체: 515 examples, 0 failures · 줄 487/487(100%) · 분기 143/150(95.33%, 게이트 90/85) · rubocop 64 files no offenses. 문서: .claude/rules/ruby.md 는 예산 6835B 에 1B 남짓 남아 함정 한 줄(read_nonblock 이지 rbuf_fill 이 아니다)을 넣지 못했다 — http.rb 주석이 소유한다. README 에 거짓이 된 문장은 없다. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R4jYS5GwFA8V5oAqjw8pMp
…NGELOG(Changed 하나 · Security 하나) · 등록부 항목을 남은 칸으로 좁힘 - security.md: 아홉이 함께 움직이는 새 규칙 한 줄(재는 것은 `ResponseReadDeadline*` 흘리기 시험, 남은 칸은 등록부). 6633 → 6807(+174B, 래칫 (1)). 교환: 한 언어 사실의 포인터 「Rust needed one more thing」(원천 rust.md 74행). - 플레이북 79행: 「admin timeout injection」 → 「Response deadline」(흘리기 시험이 옛 점검까지 잰다). 22684 → 22882(+198B, 래칫 (1)). - CHANGELOG [Unreleased]: Changed — Java·Kotlin·Python·Ruby 의 admin 성공 본문(목록·raw())도 타임아웃 안에 다 와야 한다(⚠️ 동작 변화) · Security — 다섯 레인의 응답 기한(1 초 설정에 7 초 → 기한 + 읽기 하나 · JVM TLS 레코드 33.6 초). - 등록부 `response-read-deadline-divergent`: 열린 채, 표제를 남은 칸으로(python sync·JVM admin 의 헤더·청크 틀 · node JWKS 는 jose 기본 5 초) · 3 KB 수정 전 측정 주석 → 남은 칸의 측정·고칠 자리·기각 안·재는 법. 원본 202107 → 200642B(앵커를 따라 내림) · 모양 28/0 · known-gaps drift 0(열림 131 / 닫힘 153). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R4jYS5GwFA8V5oAqjw8pMp
…ngine 생성자 변경이 「공개 API 파괴」로 울었다 Kotlin `internal` 클래스는 바이트코드에서 public 이고 이름이 망글되지 않아, #886 의 망글 멤버 제외로는 못 거른다 (그 커밋이 「남은 오탐 부류」로 적어 둔 자리). 응답 기한이 `AdminEngine(engine)` → `AdminEngine(engine, readTimeoutMillis)`, `Exchange(request, context)` → `Exchange(request, readTimeoutMillis)` 로 바꾸자 CONSTRUCTOR_REMOVED 로 rc 1 이었다. 소스의 최상위 `internal` 클래스 선언(`git ls-files 'src/main/kotlin/*.kt'` · 줄 첫머리 `internal … class|interface|object`)에서 그 클래스와 `$*`(안의 클래스 — 실효 가시성이 internal 이하)를 파생해 뺀다. 손 목록이 아니다. 하나도 못 찾으면 공허라 실패. 대조(워크플로 YAML 에서 그 단계의 run 본문을 그대로 꺼내 실행 · 기준선 1.0.7 · japicmp 0.26.1): - 이 브랜치의 JAR → rc 0(최상위 internal 7 개) — 옛 제외로는 rc 1(REMOVED 4) - 공개 `TokenSet.class` 를 지운 JAR → rc 1(REMOVED CLASS TokenSet) - 파일 집합을 비운 사본 → rc 1(공허) 남은 오탐: 공개 클래스 안의 internal 클래스 · 최상위 internal 함수(`*Kt`). public → internal 로 좁히는 것은 바이트코드가 그대로라 이 제외와 무관하게 japicmp 가 원래 못 본다(소스 전용 파괴). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R4jYS5GwFA8V5oAqjw8pMp
xzawed
force-pushed
the
response-deadline
branch
from
October 29, 2026 05:05
86b17ee to
3ed9090
Compare
- java admin `ResponseReadDeadlineTest`: 닫지 않은 `SlowStream` 둘(S2095 BLOCKER — 신뢰성 등급 5 의 원인)을 try-with-resources 로. - java auth·admin 의 `record Piece(long, byte[])`(S6218 — 배열 성분 record 의 equals·hashCode·toString 은 identity)를 같은 이름의 접근자를 둔 final 클래스로 — 호출부는 그대로다. 비교하지 않는 조각이라 값 의미를 약속하지 않는다. - java auth `volatile Runnable beforeLast`(S3077) → `AtomicReference<Runnable>`(JwksEmptyKeysetTest 와 같은 꼴). - ruby `paced_server.rb` 의 `x = … or return`(rubydre:S7916) → 두 줄(`return unless line`). java auth 12/12 · admin 7/7 · ruby response_read_deadline_spec 10/10 · RuboCop 깨끗. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R4jYS5GwFA8V5oAqjw8pMp
|
Owner
Author
|
두 번째 Sonar 게이트(신뢰성 등급 5)는 응답 기한 시험의 이슈 여섯이었다 — java admin 🤖 Generated with Claude Code |
xzawed
added a commit
that referenced
this pull request
Oct 29, 2026
… 응답마다 본문 상한 + 틀 몫 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
xzawed
added a commit
that referenced
this pull request
Oct 29, 2026
…록부 두 칸을 닫음 - 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
xzawed
added a commit
that referenced
this pull request
Oct 29, 2026
… 응답마다 본문 상한 + 틀 몫 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
xzawed
added a commit
that referenced
this pull request
Oct 29, 2026
…록부 두 칸을 닫음 - 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
xzawed
added a commit
that referenced
this pull request
Oct 29, 2026
…바이트 예산 (response-read-deadline-divergent · response-framing-unbounded) (#893) * test(python): TLS 가짜 IdP 의 자체 서명 인증서를 conftest 로 옮긴다 — 응답 기한 시험도 쓴다 `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 * fix(python): sync 응답의 마감을 소켓의 원문 읽기마다 본다 — 머리·청크 크기 줄·트레일러·TLS 레코드까지, 응답마다 본문 상한 + 틀 몫 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 * test(python): gzip admin 성공 본문의 꼬리를 흘리는 칸을 잰다 — urllib3 read1 이 풀린 것 없이 다시 읽는 길 (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 * docs: python 소켓 기한 — CHANGELOG(Changed 바이트 예산 · Security 소켓 읽기마다) · 등록부 두 칸을 닫음 - 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(python): urllib3 판에 따라 갈리는 대조군을 판으로 가른다 · admin 성공 대조군이 실패하면 원인 사슬 전부를 싣는다 - 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 * test(python): admin 성공 대조군이 실패하면 python-keycloak 이 감싸기 전의 원래 예외를 싣는다 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 * test(python): admin 성공 대조군 — 트레일러 수를 묶는 CPython 에서는 끊은 것이 http.client 인지 본다 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 --------- 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.



무엇
판정 2026-10-29(사람 위임 「권장안」, #881)
response-read-deadline-divergent를 수행한다:읽기마다만 걸려 있던 다섯 레인(java·kotlin·python sync/aio·ruby)과 기한이 없던 .NET 디스커버리·JWKS 본문을 고쳤다. go·rust·node·php·.NET auth/admin 은 이미 전체 기한이었다(2026-10-27 실측 — 등록부).
공통 조건(레그 둘): 읽기 타임아웃 1 s · 마지막 10 B 를 700 ms 마다(읽기 타임아웃은 한 번도 안 터진다). 시험 한계 3 s = 기한 1 s + 이미 도는 읽기 하나 1 s + 이 PC 의 부하 여유 1 s — 옛 코드는 그보다 4 s 이상 길다.
BoundedTransport— 요청을 쓰기 직전 스레드별 기한을 걸고(:261) 두 소켓 팩토리가 읽기마다 앞뒤로 본다(평문:227· TLS 는 JSSE 아래 평문 소켓:695· 검사:538–563) · admin:AdminEngine의Exchange가 보낸 시각을 찍고(:141)Settled가 읽기마다 본다(:215–237)BoundedTransport.kt걸기:716· 기한 소켓:813–841· 팩토리:547/:647·admin/AdminClient.kt:294·:361–378_internal/deadline.py:60deadline()(보내기 직전 단조 시계 →requests.ReadTimeout) ·:76reader()가read1로 읽고 앞뒤로 본다 —read(amt)는amt를 채울 때까지 기다려 한 번이 여러 타임아웃이 된다KeycloakTransportErrordeadline.py:93within()— 교환 전체(연결·보내기·헤더·본문)를asyncio.wait_for(바닥 3.10 이라asyncio.timeout없음) →httpx.ReadTimeout· JWKS 재조회는 여전히 shield 된 자기 태스크 안http.rb:232ReadDeadline—on_connect에서 소켓에 붙이고(:267) 요청마다read_timeout으로 걸고(:273)read_nonblock마다 본다(:239) —Net::ReadTimeout→TransportError. 버퍼 채우기마다가 아니라 읽기마다인 이유: TLS 레코드를 한 바이트씩 흘리면 채우기 하나가 계속 기다린다(채우기마다로 옮기면 TLS 22,939 ms 에 수락)BoundedDocumentRetriever.cs:84–88— 호출자 토큰에 이은CancelAfter(HttpClient.Timeout)를SendAsync직전에 걸고 그 토큰은 본문 읽기에만(:99— 헤더는 전처럼HttpClient.Timeout) · 터지면HttpClient.Timeout이 늦은 헤더에 내는 것과 같은TaskCanceledException(TimeoutException위)(:105–113).NET 재조회 규칙: 기한은 조회의 것이다 — 호출자 토큰을 안에 잇고 그 반대는 없다. 그래서
CancellationToken.None재조회는 호출자가 무엇을 취소하든 SDK 타임아웃 아래에서 끝까지 가고(security.md「never roll the stamp back on cancellation」), 기한 만료는 백오프의 실패로 센다. JWKS 수명 브랜치(#887)의 취소 세기 시험은 그대로 통과한다.변이(레그): java/kotlin — 해제를 빼면 잡힘 · admin 의 읽은 뒤 검사를 빼면 잡힘 · 검사를 본문 스트림으로만 옮기면(명세의 기본 자리) 본문 흘리기는 막히지만 헤더·청크·트레일러(≈7.0 s)와 TLS 레코드(33.7 s)가 지나간다 → 시험이 소켓 자리를 고정한다. python —
read1대신read면 sync 흘리기가 7,069–7,074 ms 에야 실패 · 읽은 뒤 검사를 빼면 단위 시험 실패 ·wait_for를 빼면 aio 흘리기가 7,066–7,085 ms 에 수락. ruby — 위의 채우기마다 변이.스위트(레그 — 각자 바탕에서): java
mvn verify(IT 포함) core 53 · auth 235 · admin 119 · keycloak-sdk 38 · IT 17 — 실패 0, 커버리지 게이트 충족(auth 가지 210/212 · admin 96/98), 바이트코드 하한 OK · kotlincleanTest build test koverVerify ktlintCheck370(바탕 351 + 새 19) · Kover 가지 333/362 · integrationTest 11/11 · .NET 단위 559(바탕 547) · 줄 97.95% · 가지 94.38% · Release 경고 0 · 통합 10/10 · python 1262 통과(바탕 1234) · 커버리지 100% · ruff·format·mypy strict 깨끗 · ruby 515 예제 실패 0 · 줄 487/487 · 가지 143/150 · RuboCop 깨끗.admin 의 성공 본문(목록·
raw()포함)도 이제 설정한 타임아웃 안에 다 와야 한다 — Java·Kotlin(Settled를 모든 admin 응답이 지난다)·Python·Ruby. 느린 링크의 큰 목록이 지금까지는 끝까지 받혔고 이제 타임아웃 오류다. Go·Rust·Node·PHP·.NET admin 은 이미 그랬다(.NET 은HttpClient.Timeout이 응답 전체). CHANGELOG### Changed에 적었다.남은 칸 — 등록부 항목을 열어 둔다
판정의 「응답 전체」가 두 자리에서 아직 덜 섰다. 닫으면 거짓이라 항목은 열린 채 표제를 남은 칸으로 좁혔다:
http.client한 호출 안이라 7,009–7,027 ms 뒤에야 실패한다(전에는 7,009–7,014 ms 에 수락 — aio 는 약 1.0 s · ruby 1,447–1,458 ms). gzip 성공 본문이 아직 빈 값으로 풀리는 동안 urllib3 가 계속 읽는 길도 같은 부류(미측정). 고치려면 urllib3 아래 소켓 훅 — 판정한 설계 밖이다.createRemoteJWKSet에timeoutDuration이 없어 jose 기본 5 s 다 — 설정readTimeoutMs(기본 30 s)가 아니다. 엄격한 쪽이고 명세가 node 를 「이미 유계」로 분류해 레그 범위 밖이었다.asyncio.wait_for가 교환이 끝나는 순간 도착한 호출자 취소를 삼킬 수 있다(CPython 결함 — PM 이 로컬 3.13.11 의asyncio/tasks.py:506에서wait_for가async with timeouts.timeout(timeout)위인 것을 확인했다 · 「3.12 부터」는 레그 판독). 재현 시험 없음 · CI 매트릭스가 그 판을 돈다.문서(PM)
.claude/rules/security.md「Response read deadline」 한 줄(아홉이 함께 움직이는 규칙 · 재는 것은ResponseReadDeadline*시험 · 남은 칸은 등록부). 6633 → 6807(+174B, 래칫 (1)). 교환: 이 변경으로 거짓이 된 문장이 없어, 한 언어 사실의 포인터 「Rust needed one more thing」(원천rust.md74행)를 지웠다.[Unreleased]: Changed(동작 변화) 하나 · Security(다섯 레인) 하나.read1· ruby 읽기마다 · JVM 소켓 자리)은 넣지 않았다 — 셋 다 변이로 시험이 고정하고 코드 주석이 그 자리에 있으며, python.md·ruby.md 는 예산 여유가 26B·약 1B 다.kotlin japicmp — internal 클래스 오탐을 파생 제외로(86b17ee)
첫 CI 에서 kotlin japicmp 가
AdminEngine(engine)→AdminEngine(engine, readTimeoutMillis)·Exchange(request, context)→Exchange(request, readTimeoutMillis)를 CONSTRUCTOR_REMOVED 로 막았다. 둘 다 Kotlininternal이지만 바이트코드에서는 public 이고 이름이 망글되지 않아 #886 의 망글 멤버 제외로는 못 거른다(#886 이 「남은 오탐 부류」로 적어 둔 자리).TokenResponseGuard.kt도AdminEngine을 써서private(JVM package-private)로 내릴 수도 없다.그래서 소스의 최상위
internal클래스 선언에서 파생해 그 클래스와$*(안의 클래스 — 실효 가시성이 internal 이하)를 뺀다 — 손 목록이 아니다. 대조는 워크플로 YAML 에서 그 단계의run본문을 그대로 꺼내 돌렸다(기준선 1.0.7 · japicmp 0.26.1): 이 브랜치 JAR → rc 0(최상위 internal 7) · 옛 제외 → rc 1(REMOVED 4) · 공개TokenSet.class를 지운 JAR → rc 1 · 파일 집합을 비운 사본 → rc 1(공허). public → internal 로 좁히는 것은 바이트코드가 그대로라 이 제외와 무관하게 japicmp 가 원래 못 본다(소스 전용 파괴).PM 재검증
TokenResponseGuard·.NETBearerHandler를 바꿨으므로 언어 스위트는 이 PR 의 CI 가 새 바탕에서 다시 잰다.check-docs --strictrc 0 ·test-security-defaults485/0 · 등록부 모양 28/0 · known-gaps drift 0(열림 131 / 닫힘 153) · 착지 게이트 16/0 · 보이지 않는 글자 0.🤖 Generated with Claude Code
https://claude.ai/code/session_01R4jYS5GwFA8V5oAqjw8pMp