From ef01d84bd0e1c0652568d6b34590c88d41f05d45 Mon Sep 17 00:00:00 2001 From: Thierry Boileau Date: Sun, 1 Nov 2026 14:14:58 +0100 Subject: [PATCH 1/2] gRPC client: release the call slot before the caller is notified (#1622) RestletClientStream released its admission permit in deframerClosed() only after super.deframerClosed() had returned, but the SDK notifies the call listener (and so wakes the caller) from that super call. A caller that started its next call right away could find the single permit still taken and get RESOURCE_EXHAUSTED "gRPC call limit exceeded". It made GrpcHttpValidationTestCase fail intermittently on CI (4 runs in a row). The permit is now released first. Added a regression test alternating 200 failing and healthy calls with one slot. Co-Authored-By: Claude Sonnet 5.5 --- .../grpc/internal/RestletClientStream.java | 13 ++++--- .../ext/grpc/GrpcHttpValidationTestCase.java | 36 +++++++++++++++++++ 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/internal/RestletClientStream.java b/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/internal/RestletClientStream.java index d22ab4e941..d52ca2e315 100644 --- a/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/internal/RestletClientStream.java +++ b/org.restlet.ext.grpc/src/main/java/org/restlet/ext/grpc/internal/RestletClientStream.java @@ -380,14 +380,13 @@ protected void http2ProcessingFailed( @Override public void deframerClosed(boolean partial) { - try { - super.deframerClosed(partial); - } finally { - if (!done) { - done = true; - finished.accept(RestletClientStream.this); - } + // Release the call slot first: the SDK notifies the caller from super.deframerClosed(), + // and a caller that starts its next call right away must not find the slot still taken. + if (!done) { + done = true; + finished.accept(RestletClientStream.this); } + super.deframerClosed(partial); } } diff --git a/org.restlet.ext.grpc/src/test/java/org/restlet/ext/grpc/GrpcHttpValidationTestCase.java b/org.restlet.ext.grpc/src/test/java/org/restlet/ext/grpc/GrpcHttpValidationTestCase.java index 8d1f1d7f96..3321577c08 100644 --- a/org.restlet.ext.grpc/src/test/java/org/restlet/ext/grpc/GrpcHttpValidationTestCase.java +++ b/org.restlet.ext.grpc/src/test/java/org/restlet/ext/grpc/GrpcHttpValidationTestCase.java @@ -9,6 +9,7 @@ package org.restlet.ext.grpc; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -189,6 +190,41 @@ void malformedBinaryMetadataFailsExplicitly() throws Exception { assertRecovered(); } + /** + * The call slot must be free as soon as the caller is told that a call failed, otherwise the + * next call of the same thread is rejected with RESOURCE_EXHAUSTED. + */ + @Test + void failedCallReleasesItsSlotBeforeTheCallerIsNotified() throws Exception { + adapter.setMaxConcurrentCalls(1); + AtomicInteger requests = new AtomicInteger(); + start( + new Restlet() { + @Override + public void handle(Request request, Response response) { + if (requests.incrementAndGet() % 2 == 1) { + response.setEntity("", MediaType.valueOf("application/grpc")); + } else { + response.setEntity( + new ByteArrayRepresentation( + frame("recovered"), + MediaType.valueOf("application/grpc"))); + response.getTrailers().add("grpc-status", "0"); + } + } + }); + for (int i = 0; i < 200; i++) { + int call = i; + Status status = failure(); + // Only the slot matters here: depending on timing the failure is UNKNOWN or + // UNAVAILABLE. + assertNotEquals(Status.Code.OK, status.getCode(), () -> call + ": " + status); + assertNotEquals( + Status.Code.RESOURCE_EXHAUSTED, status.getCode(), () -> call + ": " + status); + assertRecovered(); + } + } + @Test void wrongContentTypeFailsAndRecovers() throws Exception { startMalformedThenHealthy(response -> response.setEntity("not grpc", MediaType.TEXT_PLAIN)); From 7d222403303bc87adc5513c43385bfce910d5358 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Nov 2026 20:28:44 +0000 Subject: [PATCH 2/2] Document gRPC call-limit fix Co-authored-by: jlouvel <374450+jlouvel@users.noreply.github.com> --- changes.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/changes.md b/changes.md index 943d9fdc1a..89954d9b6b 100644 --- a/changes.md +++ b/changes.md @@ -218,6 +218,9 @@ Changes log their case (`Realm=`, `Charset=`), as required by RFC 9110. Issue #1455. - `ChallengeScheme.valueOf()` returned `HTTP_OAUTH` for the names of the Bearer and MAC schemes (`HTTP_Bearer`, `HTTP_MAC`) instead of `HTTP_OAUTH_BEARER` and `HTTP_OAUTH_MAC`. Issue #1618. + - The gRPC client could reject a call with `RESOURCE_EXHAUSTED` ("gRPC call limit exceeded") + right after the end of the previous one: the call slot was released after the caller was + notified. Issue #1622. - Misc - Fixed JDK HTTP client accessibility in core and WebSocket tests in Eclipse and VS Code, without adding a production module dependency.