1616
1717import java .io .IOException ;
1818import java .net .URI ;
19+ import java .net .URISyntaxException ;
1920import java .net .http .HttpRequest ;
2021import java .net .http .HttpResponse ;
2122import java .nio .charset .StandardCharsets ;
@@ -89,6 +90,18 @@ <T> T callApi(final HttpRequest request, final Class<T> type) throws SonosApiCli
8990 final SonosType sonosDeclaredClass = getTypeFromHeader (response );
9091 final String sonosDeclaredClassName = sonosDeclaredClass == null ? null : sonosDeclaredClass .getClazz ().getSimpleName ();
9192
93+ // A non-2xx response that Sonos did not describe with a structured error type would otherwise be
94+ // handed to the deserializer for the requested type, producing a confusing "converting response"
95+ // parse error. Surface the HTTP status (and a short body snippet) instead. Responses that DO carry
96+ // a declared error type still flow through to the SonosApiError handling below, regardless of status.
97+ final int status = response .statusCode ();
98+ final boolean sonosDeclaredError = sonosDeclaredClass != null && SonosType .getErrorTypes ().contains (sonosDeclaredClass );
99+ if ((status < 200 || status >= 300 ) && !sonosDeclaredError )
100+ {
101+ final String detail = isTokenResponse (request ) ? "<redacted - token response>" : bodySnippet (bytes );
102+ throw new SonosApiClientException (String .format ("Sonos API returned HTTP %d: %s" , status , detail ));
103+ }
104+
92105 // If Sonos didn't provide a type, or if one was provided and it matches what we wanted returned, proceed
93106 if (sonosDeclaredClassName == null || sonosDeclaredClassName .equals (type .getSimpleName ()))
94107 {
@@ -290,14 +303,7 @@ <T, U> T postToApi(final Class<T> returnType, final String token, final String p
290303 HttpRequest .Builder getStandardRequest (final String token , final String path ) throws SonosApiClientException
291304 {
292305 final SonosApiConfiguration configuration = apiClient .getConfiguration ();
293- final URI uri ;
294- try
295- {
296- uri = URI .create ("https://" + configuration .getControlBaseUrl () + path );
297- } catch (final IllegalArgumentException e )
298- {
299- throw new SonosApiClientException ("Invalid URI built" , e );
300- }
306+ final URI uri = buildControlUri (configuration .getControlBaseUrl (), path );
301307
302308 final HttpRequest .Builder builder = HttpRequest .newBuilder (uri )
303309 .setHeader ("Authorization" , String .format ("Bearer %s" , token ))
@@ -309,6 +315,32 @@ HttpRequest.Builder getStandardRequest(final String token, final String path) th
309315 return builder ;
310316 }
311317
318+ /**
319+ * Build the control API URI for a path, percent-encoding the path segments (which may contain
320+ * interpolated resource IDs). {@code controlBaseUrl} bundles the host with a base path, e.g.
321+ * {@code api.ws.sonos.com/control/api}, so it is split on the first {@code /} to let the multi-argument
322+ * {@link URI} constructor encode the assembled path - unlike {@link URI#create(String)}, which requires
323+ * an already-valid URI string and would reject (rather than encode) a stray space or reserved character.
324+ *
325+ * @param controlBaseUrl the configured control base URL (host + optional base path)
326+ * @param path the resource path, already interpolated with any IDs
327+ * @return the assembled, encoded URI
328+ * @throws SonosApiClientException if the URI could not be built
329+ */
330+ private static URI buildControlUri (final String controlBaseUrl , final String path ) throws SonosApiClientException
331+ {
332+ final int firstSlash = controlBaseUrl .indexOf ('/' );
333+ final String host = firstSlash < 0 ? controlBaseUrl : controlBaseUrl .substring (0 , firstSlash );
334+ final String basePath = firstSlash < 0 ? "" : controlBaseUrl .substring (firstSlash );
335+ try
336+ {
337+ return new URI ("https" , host , basePath + path , null );
338+ } catch (final URISyntaxException e )
339+ {
340+ throw new SonosApiClientException ("Invalid URI built" , e );
341+ }
342+ }
343+
312344 /**
313345 * Helper method to generate a basic GET request
314346 *
@@ -348,6 +380,20 @@ private static boolean isTokenResponse(final HttpRequest request)
348380 return path != null && path .contains ("/oauth/access" );
349381 }
350382
383+ /**
384+ * Decode a response body as text for inclusion in an error message, truncated so an unexpectedly large
385+ * body (e.g. an HTML error page from an intermediary) cannot bloat the exception.
386+ *
387+ * @param bytes the raw response body
388+ * @return a short, human-readable snippet of the body
389+ */
390+ private static String bodySnippet (final byte [] bytes )
391+ {
392+ final String body = new String (bytes , StandardCharsets .UTF_8 );
393+ final int maxLength = 512 ;
394+ return body .length () <= maxLength ? body : body .substring (0 , maxLength ) + "...(truncated)" ;
395+ }
396+
351397 /**
352398 * A timeout only applies if it is present and strictly positive - {@link HttpRequest.Builder#timeout}
353399 * and {@link java.net.http.HttpClient.Builder#connectTimeout} both reject zero/negative durations, so a
0 commit comments