Tutoriel · Salesforce · Java · Spring Boot

Un client Salesforce robuste en Java avec Spring Boot

Jeton client credentials renouvelé à la demande, RestClient typé, upsert idempotent par External ID, lots de 200 et reprises maîtrisées face aux limites de l’org.

Un accès dédié au middleware

Le middleware du premier article écrit dans Salesforce sans intervention humaine. Il lui faut donc une identité propre : une External Client App (ou une Connected App) avec le flux client credentials, exécuté au nom d’un utilisateur d’intégration dont le permission set n’ouvre que Paiement__c, les comptes en lecture et les demandes.

Le flux JWT Bearer, avec un certificat plutôt qu’un secret, convient aussi ; les deux sont comparés dans Authentifier une intégration Salesforce avec OAuth 2.0. Seul le fournisseur de jeton change, le reste du client est identique.

Obtenir et renouveler le jeton

Un jeton Salesforce n’annonce pas sa durée de vie : elle dépend de la politique de session de l’org. Plutôt que de deviner, on le garde jusqu’au premier refus 401, puis on le renouvelle.

@Component
public class SalesforceTokenProvider {

    private final RestClient http;
    private final SalesforceProperties props;
    private volatile String token;

    private record TokenResponse(String access_token, String instance_url) {}

    public SalesforceTokenProvider(RestClient.Builder builder, SalesforceProperties props) {
        this.props = props;
        this.http = builder.baseUrl(props.instanceUrl().toString()).build();
    }

    public String token() {
        String t = token;
        return t != null ? t : refresh(null);
    }

    /** Renouvelle le jeton, sauf si un autre thread l'a déjà fait depuis l'échec constaté. */
    public synchronized String refresh(String expired) {
        if (token == null || token.equals(expired)) {
            MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
            form.add("grant_type", "client_credentials");
            form.add("client_id", props.clientId());
            form.add("client_secret", props.clientSecret());
            token = http.post().uri("/services/oauth2/token")
                    .contentType(MediaType.APPLICATION_FORM_URLENCODED)
                    .body(form)
                    .retrieve()
                    .body(TokenResponse.class)
                    .access_token();
        }
        return token;
    }
}

La méthode refresh reçoit le jeton qui vient d’échouer : si plusieurs threads constatent l’expiration en même temps, un seul redemande un jeton, les autres récupèrent le nouveau.

Le client REST

Spring Boot 3.2 apporte RestClient, une API synchrone et fluide qui remplace RestTemplate. Deux réglages centralisent ce que chaque appel répéterait sinon :

  • un intercepteur qui ajoute le jeton et rejoue une fois la requête après un 401 ;
  • un gestionnaire d’erreurs qui transforme les réponses d’erreur Salesforce en exceptions typées.
@Component
public class SalesforceClient {

    private final RestClient http;
    private final SalesforceTokenProvider tokens;

    public SalesforceClient(RestClient.Builder builder, SalesforceProperties props, SalesforceTokenProvider tokens) {
        this.tokens = tokens;
        this.http = builder
                .baseUrl(props.instanceUrl() + "/services/data/" + props.apiVersion())
                .requestInterceptor(this::authenticate)
                .defaultStatusHandler(HttpStatusCode::isError, SalesforceErrors::raise)
                .build();
    }

    // Ajoute le jeton ; sur 401 (session expirée ou révoquée), renouvelle une fois et rejoue la requête.
    private ClientHttpResponse authenticate(HttpRequest req, byte[] body, ClientHttpRequestExecution exec)
            throws IOException {
        String token = tokens.token();
        req.getHeaders().setBearerAuth(token);
        ClientHttpResponse res = exec.execute(req, body);
        if (res.getStatusCode().value() == 401) {
            res.close();
            req.getHeaders().setBearerAuth(tokens.refresh(token));
            return exec.execute(req, body);
        }
        return res;
    }
}

Les délais de connexion et de lecture se règlent sur le RestClient.Builder : quelques secondes pour se connecter, une trentaine pour lire. Un appel sans délai qui reste bloqué épuise peu à peu les threads du middleware.

Upsert par External ID

Un webhook PayPal peut arriver deux fois, et le middleware peut être redémarré au milieu d’un traitement. Toute écriture doit donc être idempotente : la jouer deux fois donne le même résultat qu’une fois. L’upsert sur External ID le garantit : un PATCH sur /sobjects/Paiement__c/PayPal_Order_Id__c/{id} crée l’enregistrement s’il n’existe pas et le met à jour sinon.

@JsonInclude(JsonInclude.Include.NON_NULL)   // un champ null n'est pas envoyé, donc pas effacé
public record PaiementSf(
        @JsonProperty("Statut__c") String statut,
        @JsonProperty("Montant__c") BigDecimal montant,
        @JsonProperty("PayPal_Capture_Id__c") String captureId) {
}

@Retry(name = "salesforce")
public void upsertPaiementParCommande(String orderId, PaiementSf paiement) {
    // PATCH sur l'External ID : création si absent, mise à jour sinon. Rejouer l'appel est sans danger.
    http.patch()
            .uri("/sobjects/Paiement__c/PayPal_Order_Id__c/{id}", orderId)
            .contentType(MediaType.APPLICATION_JSON)
            .body(paiement)
            .retrieve()
            .toBodilessEntity();
}

La clé est l’identifiant de commande, écrit dans Salesforce dès la création du paiement : un événement de capture retrouve donc toujours son enregistrement et y ajoute l’identifiant de capture. Les remboursements, qui ne connaissent que la capture, utilisent l’autre External ID de la même façon.

Le DTO ne contient que les champs dont PayPal est maître. Les champs propres à Salesforce (conseiller, commentaire) ne sont jamais envoyés : ils ne risquent pas d’être écrasés.

Par lots de 200

Chaque appel compte dans le quota journalier d’API de l’org. Lors d’un rattrapage ou d’un traitement en masse, l’API sObject Collections écrit jusqu’à 200 enregistrements en un seul appel, avec un résultat par ligne :

public record Resultat(String id, boolean success, boolean created, List<Erreur> errors) {}
public record Erreur(String statusCode, String message, List<String> fields) {}

@Retry(name = "salesforce")
public List<Resultat> upsertLot(List<Map<String, Object>> paiements) {
    if (paiements.size() > 200) {
        throw new IllegalArgumentException("sObject Collections : 200 enregistrements au plus par appel");
    }
    List<Map<String, Object>> records = paiements.stream().map(p -> {
        Map<String, Object> r = new LinkedHashMap<>(p);
        r.put("attributes", Map.of("type", "Paiement__c"));
        return r;
    }).toList();

    // allOrNone=false : une ligne en erreur n'empêche pas l'écriture des 199 autres.
    return http.patch()
            .uri("/composite/sobjects/Paiement__c/PayPal_Order_Id__c")
            .contentType(MediaType.APPLICATION_JSON)
            .body(Map.of("allOrNone", false, "records", records))
            .retrieve()
            .body(new ParameterizedTypeReference<List<Resultat>>() {});
}

Le résultat est rendu dans l’ordre des enregistrements envoyés : on rapproche chaque ligne en erreur de son paiement pour la journaliser ou la remettre en file. Au-delà de quelques milliers d’enregistrements, la Bulk API 2.0 devient plus adaptée.

Traduire les erreurs Salesforce

Salesforce renvoie ses erreurs sous forme d’une liste JSON avec un errorCode. La décision importante est binaire : l’erreur est-elle passagère (on réessaie) ou définitive (on s’arrête et on alerte) ?

public final class SalesforceErrors {

    private record Item(String errorCode, String message) {}

    static void raise(HttpRequest req, ClientHttpResponse res) throws IOException {
        int status = res.getStatusCode().value();
        String body = new String(res.getBody().readAllBytes(), StandardCharsets.UTF_8);
        String code = body.contains("\"errorCode\"")
                ? JSON.readValue(body, Item[].class)[0].errorCode() : "HTTP_" + status;

        throw switch (code) {
            // Passager : quota d'API, verrou sur l'enregistrement, indisponibilité. On réessaiera.
            case "REQUEST_LIMIT_EXCEEDED", "UNABLE_TO_LOCK_ROW", "SERVER_UNAVAILABLE" ->
                    new SalesforceTransientException(code, body);
            // Définitif : donnée refusée par une règle de validation, champ inconnu… Réessayer ne sert à rien.
            default -> status >= 500
                    ? new SalesforceTransientException(code, body)
                    : new SalesforceRejectedException(code, body);
        };
    }

    private static final ObjectMapper JSON = new ObjectMapper();
}

Un quota d’API dépassé (REQUEST_LIMIT_EXCEEDED) arrive avec un statut 403 : sans ce classement par code, il serait pris pour un refus de droits et l’événement perdu.

Réessayer avec Resilience4j

L’annotation @Retry(name = "salesforce") des méthodes précédentes renvoie à une politique déclarée une fois :

resilience4j:
  retry:
    instances:
      salesforce:
        max-attempts: 4
        wait-duration: 2s
        enable-exponential-backoff: true
        exponential-backoff-multiplier: 3      # 2 s, 6 s, 18 s
        retry-exceptions:
          - com.acme.paiements.salesforce.SalesforceTransientException
          - org.springframework.web.client.ResourceAccessException   # délai dépassé, connexion refusée
  • seules les erreurs passagères sont réessayées, avec un délai croissant qui laisse l’org respirer ;
  • le nombre de tentatives reste court : au-delà, l’événement retourne dans la file du middleware, qui le reprendra plus tard sans bloquer un thread ;
  • l’annotation exige que l’appel passe par le proxy Spring : elle est sans effet sur une méthode appelée depuis la même classe.

Un circuit breaker Resilience4j peut compléter le dispositif : après une série d’échecs, il suspend les appels quelques minutes au lieu de solliciter une org indisponible. Le client est prêt ; le prochain article reçoit les événements PayPal qui l’alimentent.

🍪 Gestion des cookies
Ce site utilise des cookies pour mesurer l'audience (Google Analytics) et lire des vidéos (YouTube). Vous pouvez personnaliser votre choix.
Mentions légales
Réglages des cookies
Essentiels Nécessaires au fonctionnement du site — ne peuvent pas être désactivés.
Google Analytics Analyse d'audience (pages vues, sources de trafic). Aucune donnée personnelle identifiante.
YouTube Permet de lire les vidéos YouTube intégrées sur cette page.