Problème d'exceptions Spring : comportements divergents dans des conditions identiques

Récemment, j'ai rencontré un bug lié à la gestion des exceptions dans Spring. Voici une description simplifiée du code en cause :

public void insererDonnees() {
    try {
        Client client = new Client();
        client.setCode(9876L); // Clé primaire avec index unique
        clientDao.ajouter(client);
    } catch (DuplicateKeyException e) {
        log.error("Exception de clé dupliquée : {}", e.getMessage(), e);
        // Traitement spécifique pour DuplicateKeyException
    } catch (DataIntegrityViolationException e) {
        log.error("Exception d'intégrité : {}", e.getMessage(), e);
        // Traitement spécifique pour DataIntegrityViolationException
    } catch (Exception e) {
        log.error("Exception générale : {}", e.getMessage(), e);
    }
}

Curieusement, ce même code déployé sur différentes machines (avec une base de données unique, sans partitionnement) produisait des résultats incohérents :

  • Sur la machine A, un conflit de clé primaire générait une DuplicateKeyException, exécutant la logique correspondante.
  • Sur la machine B, le même conflit générait une DataIntegrityViolationException, exécutant une logique différente.
  • Après redémarrage de la machine B, le comportement redevenait identique à la machine A.

Analyse des exceptions dans le contexte Spring et JDBC

Encapsulation des exceptions standards par Spring

Type d'exception Origine Scénario de déclenchement
SQLIntegrityConstraintViolationException Standard JDBC (sous-classe de java.sql.SQLException) Violation d'une contrainte d'intégrité (clé primaire, clé étrangère, index unique, etc.)
DuplicateKeyException Exception Spring (Spring Data ou Spring JDBC) Insertion ou mise à jour enfreignant un index unique ou une clé primaire
DataIntegrityViolationException Exception Spring (couche d'accès aux données) Violation générale de l'intégrité des données (incluent clés dupliquées, contraintes non nulles, etc.)

Spring encapsule les exceptions JDBC standards. La classe SQLErrorCodesFactory charge des fichiers de configuration des codes d'erreur spécifiques aux bases de données :

public static final String CHEMIN_CODES_ERREUR_SQL 
    = "org/springframework/jdbc/support/sql-error-codes.xml";

Origine du problème

La classe SQLErrorCodeSQLExceptionTranslator effectue la conversion des exceptions. Elle utilise un tableau de codes d'erreur chargé dynamiquement :

if (Arrays.binarySearch(codesErreur.getCodesCleDupliquee(), codeErreur) >= 0) {
    return new DuplicateKeyException(message, sqlEx);
} else if (Arrays.binarySearch(codesErreur.getCodesIntegriteDonnees(), codeErreur) >= 0) {
    return new DataIntegrityViolationException(message, sqlEx);
}
// ... autres cas

Le tableau codesErreur est initialisé via :

try {
    String nomBase = JdbcUtils.extractDatabaseMetaData(sourceDonnees, "getDatabaseProductName");
    if (StringUtils.hasLength(nomBase)) {
        return enregistrerBase(sourceDonnees, nomBase);
    }
} catch (MetaDataAccessException ex) {
    logger.warn("Erreur lors de l'extraction du nom de la base - retour aux codes d'erreur vides", ex);
}
return new SQLErrorCodes();

Si une exception survient lors de la connexion initiale (par exemple, une connexion fermée prématurément), le champ codesErreur reste vide. Dans ce cas, Spring utilise une stratégie de repli via SQLExceptionSubclassTranslator, qui convertit toutes les SQLIntegrityConstraintViolationException en DataIntegrityViolationException :

if (ex instanceof SQLIntegrityConstraintViolationException) {
    return new DataIntegrityViolationException(message, ex);
}
// ... autres mappings de repli

Reproduction du problème

Cas erroné

Simulons une connexion défaillante au démarrage :

@Transactional
public void testerConnexion() {
    try {
        Connection connexion = DataSourceUtils.getConnection(sourceDonnees);
        connexion.close(); // Fermeture forcée pour corrompre l'initialisation

        clientDao.chercherParId(1L);
    } catch (DuplicateKeyException e) {
        log.error("Exception de clé dupliquée : {}", e.getMessage(), e);
    } catch (DataIntegrityViolationException e) {
        log.error("Exception d'intégrité : {}", e.getMessage(), e);
    } catch (Exception e) {
        log.error("Exception : {}", e.getMessage(), e);
    }
}

Après exécution, un conflit d'index unique déclenche une DataIntegrityViolationException.

Cas correct

En retirant la ligne connexion.close(), l'initialisation réusit. Un conflit d'index unique génère alors une DuplicateKeyException comme attendu.

Solutions

Ce bug a été rapporté et corrigé dans Spring Framwork (commit 670b9fd). Les solutions sont :

Solution 1 : mise à jour

Passer à Spring Framework version 5.2.9.RELEASE ou ultérieure.

Solution 2 : contournement

Étape 1 : Précharger et vérifier les codes d'erreur au démarrage :

public class PrechargeurMetadonnees {
    @PostConstruct
    public void initialiser() {
        try {
            SQLErrorCodes codes = fabriqueCodes.getCodes(sourceDonnees);
            log.info("Métadonnées préchargées : {}", JsonUtils.versJson(codes));

            String[] codesCleDupliquee = codes.getCodesCleDupliquee();
            if (ArrayUtils.isEmpty(codesCleDupliquee)) {
                log.error("Aucun code de clé dupliquée trouvé - redémarrage recommandé");
            }
        } catch (Exception e) {
            log.error("Échec du préchargement des métadonnées", e);
        }
    }
}

Étape 2 : Vérifier la base de données en cas d'exception d'intégrité :

catch (DataIntegrityViolationException e) {
    log.error("Exception d'intégrité : {}", e.getMessage(), e);
    Client existant = clientDao.chercherParIdentifiant(identifiant);
    if (existant != null) {
        // Conflit d'index unique
    } else {
        // Autre violation d'intégrité
    }
}

Étiquettes: Spring Framework Java JDBC SQL Exceptions Database Constraints

Publié le 6 juin à 20h12