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());