Backend

Java

java.net.http, in the JDK since 11. No dependencies beyond a JSON library.

PulseClient.java
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Map;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.JsonNode;

public final class PulseClient {
    private static final String BASE = "https://api.thepulseapi.com/api/v1";

    private final HttpClient http = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(5))
            .build();
    private final ObjectMapper json = new ObjectMapper();
    private final String apiKey;
    private final String organizationId;

    public PulseClient(String apiKey, String organizationId) {
        this.apiKey = apiKey;
        this.organizationId = organizationId;
    }

    public JsonNode track(String sessionId, Object signals) throws Exception {
        return post("/track", Map.of(
                "sessionId", sessionId,
                "organizationId", organizationId,
                "signals", signals));
    }

    public JsonNode rankedCatalog(String sessionId, int limit) throws Exception {
        return post("/catalog/ranked", Map.of(
                "sessionId", sessionId,
                "organizationId", organizationId,
                "limit", limit));
    }

    private JsonNode post(String path, Map<String, Object> body) throws Exception {
        HttpRequest request = HttpRequest.newBuilder(URI.create(BASE + path))
                .header("Content-Type", "application/json")
                .header("x-api-key", apiKey)
                // connectTimeout above only covers establishing the connection. This is the
                // one that bounds a server which accepted the socket and then went quiet.
                .timeout(Duration.ofSeconds(5))
                .POST(HttpRequest.BodyPublishers.ofString(json.writeValueAsString(body)))
                .build();

        HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
        if (response.statusCode() >= 300) {
            throw new IllegalStateException("PULSE " + path + ": " + response.statusCode());
        }
        return json.readTree(response.body());
    }
}

HttpClient is thread safe and pools connections, so build one and keep it. In Spring, that means a @Bean, not a new inside a controller.

@Bean
PulseClient pulseClient(
        @Value("${pulse.api-key}") String apiKey,
        @Value("${pulse.organization-id}") String organizationId) {
    return new PulseClient(apiKey, organizationId);
}

Two timeouts, not one

connectTimeout on the client and timeout on the request do different jobs. Set only the first and a server that accepts your connection and then stalls will hold the thread indefinitely.