Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/guide/client-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
73 changes: 73 additions & 0 deletions webrtc/src/main/java/dev/onvoid/webrtc/client/HttpCall.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String> 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);
}
}


Expand Down
9 changes: 4 additions & 5 deletions webrtc/src/main/java/dev/onvoid/webrtc/client/HttpIce.java
Original file line number Diff line number Diff line change
Expand Up @@ -270,12 +270,11 @@ private UnaryOperator<String> 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);
Expand Down Expand Up @@ -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();
Expand Down
15 changes: 4 additions & 11 deletions webrtc/src/main/java/dev/onvoid/webrtc/client/HttpSession.java
Original file line number Diff line number Diff line change
Expand Up @@ -381,14 +381,8 @@ private static CompletableFuture<Reply> 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());
});
}

Expand Down Expand Up @@ -431,8 +425,7 @@ private static CompletableFuture<Void> 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);
Expand Down Expand Up @@ -582,7 +575,7 @@ private static CompletableFuture<WebRtcException> 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;
Expand Down
28 changes: 28 additions & 0 deletions webrtc/src/test/java/dev/onvoid/webrtc/client/HttpCallTests.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -274,6 +295,13 @@ private static <T> T await(CompletableFuture<T> future) throws Exception {
return future.get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
}

private static HttpCall.Response response(int status, String type, String body) {
Map<String, List<String>> 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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
Loading