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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ All notable changes to the **Sinch Java SDK** are documented in this file.

### Numbers
- Extend `NumberSinchEvents` class.
- **[fix]** `EventTypeEnum`: `DEPROVISIONING_FROM_VOICE_PLATFORM` is deprecated and has to be replaced by `VOICE_PLATFORM_DEPROVISIONING`.
- **[fix]** `EventTypeEnum`: `DEPROVISIONING_TO_VOICE_PLATFORM` is deprecated and has to be replaced by `DEPROVISIONING_FROM_VOICE_PLATFORM`.
- **[feature]** Support new `internalFailureCode` field.
- **[feature]** Support new `StatusEnum` values: `IN_REVIEW`, `BLOCKED`, `COMPLETED`, `REJECTED`, `EXPIRED`.
- **[feature]** Support new `EventTypeEnum` value: `NUMBER_ORDER_PROCESSING`.
Expand All @@ -29,6 +29,7 @@ All notable changes to the **Sinch Java SDK** are documented in this file.
- **[feature]** Support `Consents` API: `listIdentities` and `listAuditRecords` endpoints

### SDK
- **[feature]** HTTP proxy support: configure an unauthenticated or authenticated (Basic) proxy via `HttpProxyConfiguration`
- **[feature]** `SinchClient` exposes a `close()` method to shut down the underlying HTTP connection pool and release all associated resources deterministically
- **[fix]** `HttpClientApache`: declare now `headersToBeAdded` as `volatile` to guarantee visibility across threads in concurrent usage
- **[fix]** `HttpClientApache`: wrap response-body `Scanner` in a try-with-resources block to prevent resource leaks; gracefully handle empty (`null`) response entities
Expand Down
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ For more information on the SDK, refer to the dedicated [Java SDK documentation
- [Getting started](#getting-started)
- [Logging](#logging)
- [Handling Exceptions](#handling-exceptions)
- [Proxy configuration](#proxy-configuration)
- [Third-party dependencies](#third-party-dependencies)
- [Examples](#examples)
- [Changelog and Migration](#changelog--migration)
Expand Down Expand Up @@ -397,6 +398,54 @@ try {
- `ApiAuthException`: authentication/authorization failures.
- `ApiMappingException`: the response payload could not be deserialized into the expected type.

## Proxy configuration

If your network environment routes outbound traffic through an HTTP proxy, provide proxy
configuration via [HttpProxyConfiguration](client/src/main/com/sinch/sdk/models/HttpProxyConfiguration.java) on the [Configuration](client/src/main/com/sinch/sdk/models/Configuration.java) builder.

When used, all connections will go through the proxy (including OAuth).

**Unauthenticated proxy:**

```java
import com.sinch.sdk.SinchClient;
import com.sinch.sdk.models.Configuration;
import com.sinch.sdk.models.HttpProxyConfiguration;

...
Configuration configuration = Configuration.builder()
.setKeyId(PARAM_KEY_ID)
.setKeySecret(PARAM_KEY_SECRET)
.setProjectId(PARAM_PROJECT_ID)
.setHttpProxyConfiguration(
HttpProxyConfiguration.builder()
.setHostname(PARAM_PROXY_HOSTNAME)
.setPort(PARAM_PROXY_PORT)
.build())
.build();
SinchClient client = new SinchClient(configuration);
```

**Authenticated proxy:**


```java
Configuration configuration = Configuration.builder()
.setKeyId(PARAM_KEY_ID)
.setKeySecret(PARAM_KEY_SECRET)
.setProjectId(PARAM_PROJECT_ID)
.setHttpProxyConfiguration(
HttpProxyConfiguration.builder()
.setHostname(PARAM_PROXY_HOSTNAME)
.setPort(PARAM_PROXY_PORT)
.setUsername(PARAM_PROXY_USERNAME)
// prefer char[] over String to reduce password exposure in heap memory
.setPassword(PARAM_PROXY_PASSWORD_AS_CHAR_ARRAY)
.build())
.build();
SinchClient client = new SinchClient(configuration);
```


## Third-party dependencies
The SDK relies on the following third-party dependencies:
Expand Down
2 changes: 1 addition & 1 deletion client/src/main/com/sinch/sdk/SinchClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -449,7 +449,7 @@ private HttpClientApache getHttpClient() {
synchronized (this) {
local = httpClient;
if (null == local || local.isClosed()) {
local = new HttpClientApache();
local = new HttpClientApache(configuration.getHttpProxyConfiguration().orElse(null));

// set SDK User-Agent
String userAgent = formatSdkUserAgentHeader();
Expand Down
109 changes: 108 additions & 1 deletion client/src/main/com/sinch/sdk/http/HttpClientApache.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import com.sinch.sdk.core.http.URLParameter;
import com.sinch.sdk.core.models.ServerConfiguration;
import com.sinch.sdk.core.utils.Pair;
import com.sinch.sdk.models.HttpProxyConfiguration;
import java.io.File;
import java.io.IOException;
import java.nio.charset.Charset;
Expand All @@ -31,14 +32,22 @@
import java.util.Scanner;
import java.util.logging.Logger;
import java.util.stream.Collectors;
import org.apache.hc.client5.http.ClientProtocolException;
import org.apache.hc.client5.http.HttpResponseException;
import org.apache.hc.client5.http.auth.AuthScope;
import org.apache.hc.client5.http.auth.CredentialsProvider;
import org.apache.hc.client5.http.entity.mime.MultipartEntityBuilder;
import org.apache.hc.client5.http.impl.auth.CredentialsProviderBuilder;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.HttpClientBuilder;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.client5.http.impl.routing.DefaultProxyRoutePlanner;
import org.apache.hc.core5.http.ClassicHttpRequest;
import org.apache.hc.core5.http.ClassicHttpResponse;
import org.apache.hc.core5.http.ContentType;
import org.apache.hc.core5.http.Header;
import org.apache.hc.core5.http.HttpEntity;
import org.apache.hc.core5.http.HttpHost;
import org.apache.hc.core5.http.io.entity.StringEntity;
import org.apache.hc.core5.http.io.support.ClassicRequestBuilder;
import org.apache.hc.core5.http.support.AbstractMessageBuilder;
Expand All @@ -47,12 +56,48 @@ public class HttpClientApache implements com.sinch.sdk.core.http.HttpClient {

private static final Logger LOGGER = Logger.getLogger(HttpClientApache.class.getName());

/**
* HTTP 407 Proxy Authentication Required. Kept as a local constant to make the guard in {@link
* #invokeAPI} self-documenting and independent of any external enum.
*/
private static final int HTTP_PROXY_AUTHENTICATION_REQUIRED = 407;

private volatile Map<String, String> headersToBeAdded;

private volatile CloseableHttpClient client;

public HttpClientApache() {
this.client = HttpClients.createDefault();
this(null);
}

public HttpClientApache(HttpProxyConfiguration proxyConfiguration) {
this.client = buildHttpClient(proxyConfiguration);
}

private static CloseableHttpClient buildHttpClient(HttpProxyConfiguration proxyConfiguration) {
if (proxyConfiguration == null) {
return HttpClients.createDefault();
}

HttpHost proxyHost =
new HttpHost(proxyConfiguration.getHostname(), proxyConfiguration.getPort());
HttpClientBuilder builder =
HttpClients.custom().setRoutePlanner(new DefaultProxyRoutePlanner(proxyHost));

if (proxyConfiguration.getUsername().isPresent()) {
// getPassword() returns a defensive copy of the internal array; HC5 receives that copy and
// owns it for the lifetime of the client. The caller does not need to keep the original
// alive.
CredentialsProvider credentialsProvider =
CredentialsProviderBuilder.create()
.add(
new AuthScope(proxyHost),
proxyConfiguration.getUsername().orElse(""),
proxyConfiguration.getPassword().orElse(new char[0]))
.build();
builder.setDefaultCredentialsProvider(credentialsProvider);
}
return builder.build();
}

public void setRequestHeaders(Map<String, String> headers) {
Expand Down Expand Up @@ -156,6 +201,20 @@ public HttpResponse invokeAPI(
HttpResponse response = processRequest(activeClient, request);
LOGGER.finest("connection response: " + response);

// HTTP 407 (Proxy Authentication Required) is normally handled transparently by Apache
// HttpClient via DefaultProxyRoutePlanner + CredentialsProvider (the 407→retry cycle
// happens inside processRequest and is invisible to this method).
// If 407 surfaces here it means proxy credentials are absent, wrong, or the proxy uses an
// unsupported auth scheme. Guard explicitly so that the OAuth-refresh block below does NOT
// misfire: some enterprise proxies include a `www-authenticate: Bearer error="expired"`
// header on 407 responses, which would incorrectly trigger an OAuth token reset.
if (response.getCode() == HTTP_PROXY_AUTHENTICATION_REQUIRED) {
LOGGER.warning(
"Proxy authentication required (HTTP 407). "
+ "Verify HttpProxyConfiguration hostname, port and credentials.");
return response;
}

// UNAUTHORIZED (HTTP 401) error code could imply refreshing the OAuth token
if (response.getCode() == HttpStatus.UNAUTHORIZED) {
boolean couldRetryRequest =
Expand All @@ -169,6 +228,15 @@ public HttpResponse invokeAPI(
}
}
return response;
} catch (ClientProtocolException cpe) {
int code = extractHttpStatusCode(cpe);
if (code > 0) {
LOGGER.severe("HTTP protocol error with status code " + code + ": " + cpe.getMessage());
throw new ApiException(
"HTTP protocol error (status code " + code + "): " + cpe.getMessage(), cpe, code);
}
LOGGER.severe("HTTP protocol error: " + cpe.getMessage());
throw new ApiException("HTTP protocol error: " + cpe.getMessage(), cpe);
} catch (Exception e) {
LOGGER.severe("Error:" + e);
throw new ApiException(e);
Expand All @@ -180,6 +248,9 @@ private boolean processUnauthorizedResponse(
HttpResponse response,
Map<String, AuthManager> authManagersByOasSecuritySchemes) {

if (null == authManagersByOasSecuritySchemes) {
return false;
}
Map<String, AuthManager> authManagersByAuthSchemes =
authManagersByOasSecuritySchemes.values().stream()
.map(authManager -> new AbstractMap.SimpleEntry<>(authManager.getSchema(), authManager))
Expand Down Expand Up @@ -348,6 +419,42 @@ HttpResponse processRequest(CloseableHttpClient client, ClassicHttpRequest reque
return client.execute(request, HttpClientApache::processResponse);
}

/**
* Extracts the HTTP status code from a {@link ClientProtocolException}.
*
* <p>Handles two cases:
*
* <ul>
* <li>{@link HttpResponseException} — carries the code directly via {@code getStatusCode()}.
* <li>Plain {@link ClientProtocolException} — Apache embeds the status line in the message
* (e.g. {@code "CONNECT refused by proxy: HTTP/1.1 407 Proxy Authentication Required"}).
* </ul>
*
* @return the HTTP status code, or {@code -1} if it cannot be determined
*/
private static int extractHttpStatusCode(ClientProtocolException e) {
if (e instanceof HttpResponseException) {
return ((HttpResponseException) e).getStatusCode();
}
String message = e.getMessage();
if (message == null) {
return -1;
}
int idx = message.indexOf("HTTP/");
if (idx < 0) {
return -1;
}
String[] parts = message.substring(idx).split(" ", 3);
if (parts.length < 2) {
return -1;
}
try {
return Integer.parseInt(parts[1]);
} catch (NumberFormatException ignored) {
return -1;
}
}

private Optional<Charset> extractCharset(AbstractMessageBuilder<?> messageBuilder) {

Header[] headers = messageBuilder.getHeaders(CONTENT_TYPE_HEADER);
Expand Down
31 changes: 31 additions & 0 deletions client/src/main/com/sinch/sdk/models/Configuration.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@ public class Configuration {
private final VerificationContext verificationContext;
private final VoiceContext voiceContext;
private final ConversationContext conversationContext;
private final HttpProxyConfiguration httpProxyConfiguration;
private final NumberLookupContext numberLookupContext;

private Configuration(
UnifiedCredentials unifiedCredentials,
ApplicationCredentials applicationCredentials,
SmsServicePlanCredentials smsServicePlanCredentials,
String oauthUrl,
HttpProxyConfiguration httpProxyConfiguration,
NumbersContext numbersContext,
SmsContext smsContext,
VerificationContext verificationContext,
Expand All @@ -38,6 +40,7 @@ private Configuration(
this.verificationContext = verificationContext;
this.conversationContext = conversationContext;
this.numberLookupContext = numberLookupContext;
this.httpProxyConfiguration = httpProxyConfiguration;
}

@Override
Expand All @@ -58,6 +61,8 @@ public String toString() {
+ conversationContext
+ ", numberLookupContext="
+ numberLookupContext
+ ", httpProxyConfiguration="
+ httpProxyConfiguration
+ "}";
}

Expand Down Expand Up @@ -175,6 +180,16 @@ public Optional<NumberLookupContext> getNumberLookupContext() {
return Optional.ofNullable(numberLookupContext);
}

/**
* Get HTTP proxy configuration
*
* @return HTTP proxy configuration
* @since 2.1
*/
public Optional<HttpProxyConfiguration> getHttpProxyConfiguration() {
return Optional.ofNullable(httpProxyConfiguration);
}

/**
* Getting Builder
*
Expand Down Expand Up @@ -213,6 +228,7 @@ public static class Builder {
VoiceContext.Builder voiceContext;
ConversationContext.Builder conversationContext;
NumberLookupContext.Builder numberLookupContext;
HttpProxyConfiguration httpProxyConfiguration;

protected Builder() {}

Expand Down Expand Up @@ -246,6 +262,7 @@ protected Builder(Configuration configuration) {
configuration.getConversationContext().map(ConversationContext::builder).orElse(null);
this.numberLookupContext =
configuration.getNumberLookupContext().map(NumberLookupContext::builder).orElse(null);
this.httpProxyConfiguration = configuration.getHttpProxyConfiguration().orElse(null);
}

/**
Expand Down Expand Up @@ -531,6 +548,19 @@ public Builder setNumberLookupContext(NumberLookupContext context) {
return this;
}

/**
* Set HTTP proxy configuration
*
* @param httpProxyConfiguration proxy configuration, or {@code null} to disable proxy
* @return Current builder
* @see Configuration#getHttpProxyConfiguration() getter
* @since 2.1
*/
public Builder setHttpProxyConfiguration(HttpProxyConfiguration httpProxyConfiguration) {
this.httpProxyConfiguration = httpProxyConfiguration;
return this;
}

/**
* Build a Configuration instance from builder current state
*
Expand All @@ -544,6 +574,7 @@ public Configuration build() {
null != applicationCredentials ? applicationCredentials.build() : null,
null != smsServicePlanCredentials ? smsServicePlanCredentials.build() : null,
oauthUrl,
httpProxyConfiguration,
null != numbersContext ? numbersContext.build() : null,
null != smsContext ? smsContext.build() : null,
null != verificationContext ? verificationContext.build() : null,
Expand Down
Loading
Loading