From 010a337306bc45e43f719d5aef4b1d5bc82f5761 Mon Sep 17 00:00:00 2001 From: Alex Andres Date: Thu, 29 Oct 2026 00:33:44 +0100 Subject: [PATCH] feat: describe WHIP and WHEP errors by their problem details Failures an endpoint answered with an error name the status and the title and detail of problem details (RFC 9457) it sent, else the start of the body, for the offer, a counter-offer's answer, the deletion, ICE restarts and trickled candidates alike. --- docs/guide/client-api.md | 2 +- .../dev/onvoid/webrtc/client/HttpCall.java | 73 +++++++++++++++++++ .../dev/onvoid/webrtc/client/HttpIce.java | 9 +-- .../dev/onvoid/webrtc/client/HttpSession.java | 15 +--- .../onvoid/webrtc/client/HttpCallTests.java | 28 +++++++ .../webrtc/client/HttpSessionTests.java | 17 +++++ 6 files changed, 127 insertions(+), 17 deletions(-) diff --git a/docs/guide/client-api.md b/docs/guide/client-api.md index c5c3f0568..1ecc62268 100644 --- a/docs/guide/client-api.md +++ b/docs/guide/client-api.md @@ -643,7 +643,7 @@ Whip.Session live = Whip.to(URI.create("https://example.com/live/whip")) .get(15, TimeUnit.SECONDS); ``` -A normal SDP answer must have status 201, a nonempty session `Location`, and Content-Type `application/sdp` (parameters are allowed). Invalid responses fail the session; a resource already named by the server is deleted even when its SDP response cannot be used. +A normal SDP answer must have status 201, a nonempty session `Location`, and Content-Type `application/sdp` (parameters are allowed). Invalid responses fail the session; a resource already named by the server is deleted even when its SDP response cannot be used. The message of a failure an endpoint answered with an error names the status, and what the endpoint says of it: the title and detail of problem details (RFC 9457, `application/problem+json`), as in `status 403: Forbidden: The stream key has expired`, or else the start of the body. When the connection fails, or stays disconnected for two seconds, the session restarts ICE: the peer gathers new candidates, and only the new ICE username fragment and password go to the session, by `PATCH` with `If-Match: *`, while the media go on as negotiated. The session's answer, with status 200 and Content-Type `application/trickle-ice-sdpfrag`, brings its own ICE information and a new `ETag`. Its candidate set replaces the old one completely, including when it is empty. Three restarts in a row that do not bring the connection back, or an endpoint that takes no restarts, end the attempts, which the error handler hears of. `session.restartIce()` restarts ICE at once, say after the network changed; a restart that fails leaves the ICE session before as it was. Restarts also work after a WHEP counter-offer; the player keeps its answerer role and updates only the ICE information. diff --git a/webrtc/src/main/java/dev/onvoid/webrtc/client/HttpCall.java b/webrtc/src/main/java/dev/onvoid/webrtc/client/HttpCall.java index 1b70bd78a..13c8f0d55 100644 --- a/webrtc/src/main/java/dev/onvoid/webrtc/client/HttpCall.java +++ b/webrtc/src/main/java/dev/onvoid/webrtc/client/HttpCall.java @@ -36,6 +36,7 @@ import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; +import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; @@ -617,6 +618,78 @@ static final class Response { String header(String name) { return headers.firstValue(name).orElse(null); } + + /** + * Describes the response for a message: its status, and what its body + * says. Of problem details (RFC 9457), which WHIP and WHEP endpoints + * may send with an error, those are the title and the detail, or the + * type if there are neither; any other body is given as it is, up to + * {@link #MAX_ERROR_BODY} chars. + * + * @return the description, such as {@code status 403: Forbidden: The + * stream key has expired}. + */ + String describe() { + String description = "status " + status; + String problem = problem(); + + if (problem != null) { + return problem.isEmpty() ? description : description + ": " + problem; + } + if (body == null || body.isEmpty()) { + return description; + } + + return description + ": " + (body.length() > MAX_ERROR_BODY ? body.substring(0, MAX_ERROR_BODY) : body); + } + + /** + * @return what the problem details of the body say, empty if no more + * than the status, or null if the body has none, or none that can be + * read. + */ + private String problem() { + String type = header("Content-Type"); + + if (body == null || type == null + || !type.split(";", 2)[0].trim().equalsIgnoreCase("application/problem+json")) { + return null; + } + + Object parsed; + + try { + parsed = Json.parse(body); + } + catch (IllegalArgumentException e) { + // Cut short at the size read, or not JSON at all. + return null; + } + + if (!(parsed instanceof Map)) { + return null; + } + + Map members = (Map) parsed; + List parts = new ArrayList<>(); + + for (String name : List.of("title", "detail")) { + Object value = members.get(name); + + if (value instanceof String && !((String) value).trim().isEmpty()) { + parts.add(((String) value).trim()); + } + } + + Object problemType = members.get("type"); + + // RFC 9457, 4.2.1: about:blank says no more than the status. + if (parts.isEmpty() && problemType instanceof String && !"about:blank".equals(problemType)) { + parts.add((String) problemType); + } + + return String.join(": ", parts); + } } diff --git a/webrtc/src/main/java/dev/onvoid/webrtc/client/HttpIce.java b/webrtc/src/main/java/dev/onvoid/webrtc/client/HttpIce.java index 3d4a8d0fa..aee9da8e2 100644 --- a/webrtc/src/main/java/dev/onvoid/webrtc/client/HttpIce.java +++ b/webrtc/src/main/java/dev/onvoid/webrtc/client/HttpIce.java @@ -270,12 +270,11 @@ private UnaryOperator answered(HttpCall request, HttpCall.Response respo restartsRefused = true; } - throw new WebRtcException("The session " + target + " takes no ICE restarts: status " - + response.status); + throw new WebRtcException("The session " + target + " takes no ICE restarts: " + response.describe()); } if (response.status / 100 != 2) { - throw new WebRtcException("The session " + target + " answered the ICE restart with status " - + response.status + ": " + response.body); + throw new WebRtcException("The session " + target + " answered the ICE restart with " + + response.describe()); } HttpSession.requireContentType(response, FRAGMENT_TYPE); @@ -382,7 +381,7 @@ private void flush() { rtc.reportError(new WebRtcException("Sending ICE candidates to the session " + target + " failed, and no more are sent: " + (error != null ? error.getMessage() - : "status " + response.status), error)); + : response.describe()), error)); } flush(); diff --git a/webrtc/src/main/java/dev/onvoid/webrtc/client/HttpSession.java b/webrtc/src/main/java/dev/onvoid/webrtc/client/HttpSession.java index abd1bbc44..7a1fcec2c 100644 --- a/webrtc/src/main/java/dev/onvoid/webrtc/client/HttpSession.java +++ b/webrtc/src/main/java/dev/onvoid/webrtc/client/HttpSession.java @@ -381,14 +381,8 @@ private static CompletableFuture post(HttpCall request, URI endpoint, Str counterOfferExpiry(response.header("Content-Type"), System.currentTimeMillis()), response); } - String body = response.body; - - if (body.length() > HttpCall.MAX_ERROR_BODY) { - body = body.substring(0, HttpCall.MAX_ERROR_BODY); - } - - throw new WebRtcException("The endpoint " + endpoint + " answered the offer with status " - + response.status + ": " + body); + throw new WebRtcException("The endpoint " + endpoint + " answered the offer with " + + response.describe()); }); } @@ -431,8 +425,7 @@ private static CompletableFuture answerCounterOffer(WebRtc rtc, HttpClient } if (response.status / 100 != 2) { throw new WebRtcException("The endpoint answered the answer for " - + reply.session + " with status " + response.status + ": " - + response.body); + + reply.session + " with " + response.describe()); } ice.attach(reply.session, response.header("ETag"), answer.sdp); @@ -582,7 +575,7 @@ private static CompletableFuture discard(WebRtc rtc, URI resour if (failure == null && response.status / 100 != 2) { failure = new WebRtcException("The endpoint answered the deletion of " + resource - + " with status " + response.status + ": " + response.body); + + " with " + response.describe()); } if (failure == null) { return null; diff --git a/webrtc/src/test/java/dev/onvoid/webrtc/client/HttpCallTests.java b/webrtc/src/test/java/dev/onvoid/webrtc/client/HttpCallTests.java index f1b587d98..b847dc8ca 100644 --- a/webrtc/src/test/java/dev/onvoid/webrtc/client/HttpCallTests.java +++ b/webrtc/src/test/java/dev/onvoid/webrtc/client/HttpCallTests.java @@ -30,6 +30,7 @@ import java.net.SocketAddress; import java.net.URI; import java.net.http.HttpClient; +import java.net.http.HttpHeaders; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.time.Instant; @@ -39,6 +40,7 @@ import java.util.Base64; import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CountDownLatch; @@ -79,6 +81,25 @@ void framedAnswersRead() throws Exception { } } + @Test + void problemDetailsDescribed() { + // RFC 9457: the title and the detail say what went wrong. + assertEquals("status 403: Forbidden: The stream key has expired", response(403, "application/problem+json", + "{\"type\":\"https://example.com/probs/key\",\"title\":\"Forbidden\",\"status\":403," + + "\"detail\":\"The stream key has expired\"}").describe()); + // The type when there is nothing else, unless it says no more than + // the status. + assertEquals("status 409: https://example.com/probs/busy", response(409, + "application/problem+json; charset=utf-8", "{\"type\":\"https://example.com/probs/busy\"}").describe()); + assertEquals("status 500", response(500, "application/problem+json", "{\"type\":\"about:blank\"}").describe()); + // Problem details that cannot be read, and other bodies, as they are. + assertEquals("status 400: {\"title\":", response(400, "application/problem+json", "{\"title\":").describe()); + assertEquals("status 404: not found", response(404, "text/plain", "not found").describe()); + assertEquals("status 503", response(503, null, "").describe()); + assertEquals("status 502: ".length() + HttpCall.MAX_ERROR_BODY, + response(502, "text/html", "a".repeat(5000)).describe().length()); + } + @Test void oversizedAnswerRefused() throws Exception { try (TestHttpEndpoint endpoint = new TestHttpEndpoint(offer -> ANSWER, 201)) { @@ -274,6 +295,13 @@ private static T await(CompletableFuture future) throws Exception { return future.get(TIMEOUT_SECONDS, TimeUnit.SECONDS); } + private static HttpCall.Response response(int status, String type, String body) { + Map> fields = type == null ? Map.of() : Map.of("Content-Type", List.of(type)); + + return new HttpCall.Response(URI.create("https://example.com/whip"), status, + HttpHeaders.of(fields, (name, value) -> true), body); + } + private static String basic(String user, String password) { return "Basic " + Base64.getEncoder().encodeToString((user + ":" + password) .getBytes(StandardCharsets.ISO_8859_1)); diff --git a/webrtc/src/test/java/dev/onvoid/webrtc/client/HttpSessionTests.java b/webrtc/src/test/java/dev/onvoid/webrtc/client/HttpSessionTests.java index a80a48714..a6836d17b 100644 --- a/webrtc/src/test/java/dev/onvoid/webrtc/client/HttpSessionTests.java +++ b/webrtc/src/test/java/dev/onvoid/webrtc/client/HttpSessionTests.java @@ -322,6 +322,23 @@ void refusedOfferLeavesNothing() throws Exception { } } + @Test + void refusalProblemDescribed() throws Exception { + // RFC 9725, 4.1: an endpoint may say why with problem details (RFC 9457). + try (TestHttpEndpoint endpoint = new TestHttpEndpoint(offer -> "{\"title\":\"Forbidden\"," + + "\"status\":403,\"detail\":\"The stream key has expired\"}", 201)) { + endpoint.answerStatus = 403; + endpoint.answerContentType = "application/problem+json"; + + ExecutionException failed = assertThrows(ExecutionException.class, + () -> await(Whip.to(endpoint.uri()).track(video).publish(rtc))); + + assertTrue(failed.getCause().getMessage().endsWith( + "answered the offer with status 403: Forbidden: The stream key has expired"), + failed.getCause().getMessage()); + } + } + @Test void foreignTrackLeavesNothing() throws Exception { WebRtc other = clientRuntime(UnaryOperator.identity());