`set_config(…, true)` ne remet pas NULL : l'erreur 22P02 qui n'apparaît que sur une connexion réutilisée
Après set_config(…, true), le réglage revient à '' et non NULL : ''::uuid lève 22P02 sur une connexion réutilisée. Reproduction, correctif NULLIF, test.
Sommaire · 6 sections
Après set_config('app.org_id', …, true), le réglage ne revient pas à NULL quand la transaction se termine : sur une connexion qui a déjà servi, current_setting('app.org_id', true) rend une chaîne vide, et ''::uuid lève l’erreur 22P02. Le correctif tient en une expression : NULLIF(current_setting('app.org_id', true), '')::uuid.
Le 19 mai 2026, le jour où j’ai ouvert le dépôt d’asap.cool, le signup ne marchait qu’une fois par connexion. Le premier inscrit sur une connexion neuve passait. Le suivant, sur la même connexion rendue par le pool, prenait un 500 avec une erreur Postgres 22P02. Le correctif est le commit e0078122, et c’est devenu la règle du projet : partout où un réglage de contexte est lu, il passe par NULLIF.
Petite précision avant de commencer : la doc PostgreSQL que je cite est celle de la version 18.6, lue le 26 octobre 2026, et la doc PgBouncer est lue le même jour. Si tu lis ça dans six mois, revérifie.
Le symptôme : un 500 qui dépend de la connexion que tu reçois
22P02 est, dans l’annexe des codes d’erreur, invalid_text_representation. Ici, c’est un cast uuid qui reçoit une chaîne vide. Ce qui rend le bug sournois, c’est qu’il ne dépend pas de ton code : il dépend de la connexion que le pool te rend. Neuve, tout passe. Réutilisée, ça casse. Un test d’intégration qui ouvre une connexion neuve à chaque fois ne le voit jamais, et à l’inscription, c’est l’écran que le futur client voit en premier.
Je n’ai pas gardé les logs du 19 mai. Le 5 novembre 2026, j’ai donc reproduit le cas avec sqlx 0.8.6, la version d’asap.cool, sur PostgreSQL 18.6 : un pool limité à une connexion, une transaction qui pose le contexte avec set_config(…, true), puis une requête qui lit current_setting('app.org_id', true)::uuid. Voici ce que sqlx renvoie, avec le code tiré de l’erreur :
error returned from database: invalid input syntax for type uuid: ""
code Some("22P02")
Les deux guillemets vides à la fin du message sont tout le diagnostic.
Ce que set_config laisse derrière lui
La doc dit de set_config(setting_name, new_value, is_local) que si is_local vaut true, la nouvelle valeur « only apply during the current transaction ». Et de current_setting(setting_name, missing_ok) qu’avec missing_ok à true, un réglage qui n’existe pas renvoie NULL. J’attendais donc NULL en dehors de la transaction. Sur une connexion qui avait déjà posé le réglage, j’ai obtenu ''. Dans un psql, ça tient en quelques lignes :
BEGIN;
SELECT set_config('app.org_id', '11111111-1111-1111-1111-111111111111', true);
COMMIT;
-- même connexion, nouvelle transaction
SELECT current_setting('app.org_id', true) IS NULL AS est_null,
current_setting('app.org_id', true) = '' AS est_vide;
SELECT current_setting('app.org_id', true)::uuid;
Sur PostgreSQL 18.6, les deux dernières requêtes répondent :
est_null | est_vide
----------+----------
f | t
ERROR: invalid input syntax for type uuid: ""
Dans une session neuve, sans le premier bloc, la même requête répond est_null à t et est_vide à NULL : le réglage n’existe pas, et le cast d’un NULL passe. C’est toute la différence entre le premier et le deuxième inscrit.
Le correctif : NULLIF, partout
NULLIF(current_setting('app.org_id', true), '')::uuid
NULLIF transforme la chaîne vide en NULL, et un uuid NULL ne fait plus lever le cast. Dans une policy, la comparaison avec NULL n’est pas vraie, donc la ligne n’est pas renvoyée : la doc dit que les lignes dont l’expression ne renvoie pas true ne sont pas traitées. Sans contexte, la requête lit zéro ligne au lieu de tomber. Le cast dans une policy est d’ailleurs un endroit où une exception coûte cher : elle ne filtre pas, elle fait tomber la requête, et c’est ce que j’ai vu en 500.
Derrière un pooler : SET ou SET LOCAL
Le cas du 19 mai n’avait pas de pooler externe : c’était un pool côté client, qui recycle des connexions. Un pooler comme PgBouncer ajoute un second niveau. En mode transaction, une connexion serveur n’est attribuée à un client que le temps d’une transaction, et la doc de PgBouncer classe SET/RESET parmi les fonctions que ce mode ne supporte jamais : il casse « a few session-based features of PostgreSQL ». Côté PostgreSQL, un SET simple, une fois la transaction validée, persiste jusqu’à la fin de la session, alors que SET LOCAL ne dure que jusqu’à la fin de la transaction, validée ou non.
Le contexte se pose donc dans la même transaction que la requête qu’il protège, avec set_config(…, true), l’équivalent de SET LOCAL. C’est ce que fait OrgTx sur asap.cool, qui pose le réglage dans son constructeur. Et ça ne dispense pas du NULLIF, comme le 19 mai l’a montré.
Pour le voir, j’ai monté le 5 novembre 2026 un PgBouncer 1.26.1 en pool_mode = transaction devant PostgreSQL 18.6, avec une seule connexion serveur, et j’ai ouvert deux clients psql l’un après l’autre. Le client A fait un SET simple, le client B lit le réglage :
-- client A
SET app.org_id = '11111111-1111-1111-1111-111111111111';
-- client B, autre connexion cliente, même processus serveur (pid 233)
pid_b | vu_par_b
-------+--------------------------------------
233 | 11111111-1111-1111-1111-111111111111
B lit l’organisation de A. Avec une policy qui lit ce réglage, B voit les lignes de A, sans erreur. Même essai après redémarrage du pooler, avec SET LOCAL dans une transaction côté A :
-- client B, même processus serveur (pid 246)
pid_b | est_null | est_vide
-------+----------+----------
246 | f | t
Plus de fuite, mais la chaîne vide revient : c’est exactement l’état qui a cassé mon signup. Le SET LOCAL protège l’isolation, le NULLIF protège la requête.
Un test qui rejoue deux requêtes sur la même connexion
Pour voir l’erreur et la correction dans le même script, il faut deux transactions sur la même connexion et un rôle soumis à la RLS (un superuser contourne la policy, donc le cast ne s’exécute pas) :
-- exécuté par un superuser
CREATE ROLE demo_app LOGIN;
GRANT CREATE, USAGE ON SCHEMA public TO demo_app;
SET ROLE demo_app;
CREATE TABLE notes (id serial primary key, org_id uuid, body text);
INSERT INTO notes (org_id, body) VALUES ('11111111-1111-1111-1111-111111111111', 'A');
ALTER TABLE notes ENABLE ROW LEVEL SECURITY;
ALTER TABLE notes FORCE ROW LEVEL SECURITY;
CREATE POLICY isolation ON notes
USING (org_id = current_setting('app.org_id', true)::uuid); -- version fragile
BEGIN;
SELECT set_config('app.org_id', '11111111-1111-1111-1111-111111111111', true);
SELECT count(*) FROM notes; -- 1
COMMIT;
BEGIN;
SELECT count(*) FROM notes; -- ERROR: invalid input syntax for type uuid: ""
COMMIT; -- la transaction a échoué : psql répond ROLLBACK
Les valeurs en commentaire sont celles de PostgreSQL 18.6. Remplace ensuite la policy par la version avec NULLIF : la seconde transaction renvoie 0.
ALTER POLICY isolation ON notes
USING (org_id = NULLIF(current_setting('app.org_id', true), '')::uuid);
Dans une suite de tests applicative, la même idée tient en limitant le pool à une connexion et en enchaînant deux transactions, la seconde sans contexte : le test échoue tant qu’un cast est resté sans NULLIF. C’est ce que fait la reproduction sqlx plus haut, avec son pool d’une connexion.
Ce que cet incident dit de la RLS
L’isolation tient ici à un paramètre de session, et un paramètre vide n’est pas un paramètre absent. C’est pour ça que, le 12 juillet 2026, j’ai remplacé la fonction set_org_context par OrgTx, qui pose le contexte dans son constructeur, comme je le raconte dans l’article sur l’architecture. Reste à vérifier que le rôle qui exécute tout ça est bien soumis à la RLS, ce que fait l’article sur le rôle propriétaire, avec les requêtes à lancer.





