Gérer les erreurs en PHP

Posted on Sep 22, 2024

La gestion des erreurs est centrale dans la conception de programme résilient, pourtant c’est un sujet que je n’avais jamais vraiment réfléchi. C’est comme ça en quelque sorte, comme s’il n’y avait pas lieu de s’en préoccuper. Pourtant, j’ai toujours programmé dans divers langages de programmation, pas seulement en PHP.

Ce n’est que très récemment que j’ai travaillé un peu avec Rust pour suivre l’engouement, puis Go qui lui est souvent opposé en raison des approches très différentes… J’ai aussi papillonné sur Gleam, fait des aller-retours dans le monde des langages fonctionnels. C’est là que j’ai appris à penser la gestion des erreurs autrement que celle que j’ai toujours connue, celle de PHP et des langages dont il s’inspire..

Qu’on se le dise, la gestion des erreurs en PHP est catastrophique en termes de DX (developer experience). On récupère soit des null, soit des codes d’erreurs depuis un int, soit un bool voire un false, ou bien des exceptions qui sautent de partout. La dernière option étant aujourd’hui celle que je deteste le plus et qui me semble présenter le plus d’inconvénients pour une application résiliente.

En tant que développeur, je veux qu’un null retourné soit un vrai null, pas une erreur déguisée. Qu’un int soit un int et non pas un code d’erreur. De même pour un bool|false. Je veux des erreurs qui sont des erreurs.

Le cas des exceptions

1. Les exceptions ne sont pas une solution

Oui, elles sont utilisées partout, mais elles posent un problème majeur: elles font paniquer l’application si elle ne sont pas gérées correctement. Evidemment on peut avoir un fallback vers une erreur 500 en web, idéalement dès qu’une exception est levée. Mais est-ce vraiment une solution ? J’aimerais bien savoir pourquoi cette exception est levée, et peut-être réagir différent suivant ce qui arrive. Mais pour cela il faudrait pouvoir connaitre chaque cas possible d’exception à cet endroit du code.

2. Il est très difficile de prévoir toutes les exceptions

De part leur nature, même avec de l’analyse statique il est souvent difficile de savoir si une méthode va lancer une exception et quel pourra être son type. Les exceptions peuvent être lancées par des fonctions appelées dans des blocs de code plus profond, rendant la tâche bien complexe. Plus on utilise de librairies tierces, plus c’est compliqué à gérer.

Le simple fait que le langage permette les exceptions fait que de facto elles seront utilisées et pourront arriver de n’importe où.

3. Au final c’est un enfer de try…catch, ou rien

On se retrouve avec des catch très long si on veut tout prévoir, mais dans la réalité une capture globale est plus souvent faite, par gain de temps.

Ailleurs, c’est quand-même mieux

L’herbe n’est pas toujours plus verte ailleurs mais pour le coup, c’est bien le cas. Voyons rapidement cela.

En Go

Les fonctions en Go peuvent renvoyer plusieurs valeurs, et donc potentiellement un résultat et…une erreur. Par exemple


const ErrUnableToCallApi = errors.New("Mon super message d'erreur qui dit que je ne peux pas appeler l'API")

func something() (*ApiResult, error) {
  // some logic
  if !someBoolThatSayConnectionIsOk {
    return nil, ErrUnableToCallApi
  }
  // some logic
  return &apiResult, nil
}

myApiCall, err := something()
if err != nil {
  // Je sais que j'ai une erreur, je peux la gérer
}
// Sinon je fais ce que je dois faire

Cette approche est appelée error as value. C’est simple et très efficace. Les erreurs sont renvoyées comme toute autre valeur.

Je ne suis pas obligé de vérifier si une erreur est présente, il est toujours possible de ne pas récupérer cette valeur en faisant : myApiCall, _ := something() mais on sait tous que c’est un acte de flemme et de mauvaise pratique. C’est flagrant, ça ne fait pas plaisir.

Mais le problème, c’est que je ne sais juste qu’une erreur est retournée, pas vraiment quelle est cette erreur. Du moins, le langage ne me force pas à prévoir le cas de chaque type d’erreur possible qui peut être retournée. On est presque dans le try...catch générique, mais en mieux, les erreurs sont gérées localement.

Dans les langages type OCaml, Rust, Gleam, Haskell, etc

Pour les langages fonctionnels ou d’inspiration fonctionnel, la gestion des erreurs est encore plus intéressante. On expose des types Result (Ok, Error) qui contiennent ce que l’on souhaite.

Je propose un exemple en Gleam, que j’ai plus eu l’occaison de pratiquer, mais le code serait assez proche en Rust ou Ocaml/ReasonML.

// Un air de "déjà vu" oxydé
pub type ApiError {
  ConnectionError
  Unauthorized
  NotFound
  MovedResource(string)
}

fn something() -> Result(String, ApiError) {
  // some logic
  case response_status {
    401 -> Error(Unauthorized)
    301 -> Error(MovedResource("https://courteau.dev"))
    // autres cas...
    _ -> Ok(response_body)
  }
}

fn main() {
  let my_result = something()

  let message = case my_result {
    Ok(body) -> body
    Err(err) -> case err {
      NotFound -> "L'url n'existe pas"
      ConnectionError -> "Problème de connexion"
      MovedResource(url) -> "Le contenu à bougé, il est ici : " <> url 
      // etc...
    }
  }

  message |> io.debug
}

Que se passe-t-il ? Je définis un type ApiError qui possède plusieurs constructeurs. Ce sont en quelques sortes des valeurs de ce type personnalisé. Dès lors, renvoyer une valeur de ce type permet à l’interpréteur les valeurs possibles.

Pour un Int, les valeurs sont tous les entiers, par exemple.

Le type Result a deux possibilités: soit il est Ok, soit Error. Le premier type spécifié est le type renvoyé en cas de Ok, le second pour l’Error. Simple et efficace.

Si l’on compare à l’approche utilisée en Go, c’est plus précis puisque je sais quelles erreurs je peux avoir en retour et que je suis forcé de les gérer. Ici ce sont des types renvoyés, et donc à gérer comme tout autre type.

Qu’est-ce que cela me garanti ? Qu’à l’execution, je ne peux pas avoir de surprise. Je suis forcé de prévoir un comportement en cas d’erreur. L’imprévu n’existe presque plus.

Cette contrainte peut sembler lourde mais la DX de Gleam est très bonne et l’interpréteur qui intégre aussi un LSP nous guide pendant toute l’étape de développement. Il est impossible d’oublier un cas problématique.

Comment on fait ça en PHP ?

Ca serait bien de pouvoir savoir quelles erreurs peuvent survenir après un appel de fonction, et d’être obligé de les gérer toutes. Plus de suprise a l’execution ou dans x semaines

Avec un peu de reflexion, c’est possible de manière assez classique en utilisant des…DTO/Value Objects ou appelez-ça comme vous le souhaitez.

Avec une solution native maison

Les types de Gleam peuvent se traduire par une interface. Chaque constructeur peut se traduire par une class.

Oui, Gleam n’est pas un langage orienté objet (et tant mieux), mais gardons cette approximation vers PHP.

Attention, on perd quand-même dans cette traduction une grande partie du type-system des langages de type ML qui repose sur du hindley milner.

Si l’on réecrit notre code PHP:

interface ApiError
{
}

class NotFound implements ApiError
{
}
class ConnectionError implements ApiError
{
}
class Unauthorized implements ApiError
{
}
class Moved implements ApiError
{
    public function __construct(public string $url)
    {
    }
}

// Un peu de hasard ne fait jamais de mal
function something(): ApiError|string
{
    $r = mt_rand(0, 4);

    return match ($r) {
        0 => new ConnectionError(),
        1 => new Unauthorized(),
        2 => new NotFound(),
        3 => new Moved("https://courteau.dev"),
        4 => "Hello, world!",
    };
}

$val = something();

echo match (true) {
    $val instanceof ApiError => match (true) {
        $val instanceof NotFound => "Page introuvable",
        $val instanceof Unauthorized => "Mauvais ids",
        $val instanceof Moved => "La page n'est plus là, elle est là :" .
            $val->url,
        $val instanceof ConnectionError => "Connexion impossible",
    },
    default => "Contenu de la page : " . $val,
};

Avec un peu de chance, si la RFC sur le pattern matching passe, on pourrait avoir une meilleure DX :

$result = match ($val) is {
    NotFound => 'Introuvable',
    ConnectionError => 'Problème de connexion',
    // etc...
};

Bon c’est pas mal mais il faudrait mettre cela dans un type Result maintant !

<?PHP

class ApiResult
{
    public function __construct(
        protected ?string $value = null,
        protected ?ApiError $error = null
    ) {
    }

    public function isOk(): bool
    {
        return $this->error === null;
    }

    public function error(): ApiError
    {
        return $this->error;
    }

    public function value(): string
    {
        return $this->value;
    }
}

/*
* ...
*/

// Un peu de hasard ne fait jamais de mal
function something(): ApiResult
{
    $r = mt_rand(0, 4);

    return match ($r) {
        0 => new ApiResult(error: new ConnectionError()),
        1 => new ApiResult(error: new Unauthorized()),
        2 => new ApiResult(error: new NotFound()),
        3 => new ApiResult(error: new Moved("https://courteau.dev")),
        4 => new ApiResult(value: "Hello, world!"),
    };
}

$val = something();

// J'ajoute ($val = ...) pour le correct type-hinting
if ($val->isOk() && ($val = $val->value())) {
    echo "Contenu de la page : " . $val;
} else {
    /** @var ApiError */
    $val = $val->error();
    echo "Error " .
        match ($val::class) {
            NotFound => "Page introuvable",
            Unauthorized => "Mauvais ids",
            Moved
                => /** @var Moved $val*/ "La page n'est plus là, elle est là :" .
                $val->url,
            ConnectionError => "Connexion impossible",
            default => "Unknown error",
        };
}

Simple et efficace. On tombe cependant sur un problème de taille dans le match, PHP ne nous dira jamais si on a oublié un cas particulier. Il faut alors être consciencieux et regarder quelles erreurs implémentent ApiError.

Malheureusement on ne pourra pas faire mieux, et ce sera surtout le travail du LSP. Phpactor ne gère pas encore ce cas, peut-être que Phpstorm le fait.

Avec la classique PhpResult

Avec l’engouement pour Rust, des librairies sont apparues. La plus rependue me semble être Result-Type de Graham Campbell. On peut aussi citer dans le cas des Options, la lib PHP Option.

Et cela permet d’avoir ce type de code:

if (!$unit->success()->isDefined()) {
    //Je peux récupérer mon erreur
    $error = $unit->error()->get();
    // Et maintenant je peux la gérer suivant son type/msg
    /* ... */
}

// Annotation pour l'exemple ici
/** @var UnitValueObject */
$unit = $unit->success()->get();

On approche une DX correcte mais ce n’est plus vraiment PHP qui fait le travail, c’est PHPStan qui doit le faire. On dépend grandement des generics de l’analyse statique, et cela n’est pas infaible.

J’ai personnellement du mal avec la syntaxe pour récupérer les résultats, et je préférerais un simple:

if ($unit->isError()) {
    // On recupère l'erreur
    $error = $unit->unwrap();
}
if ($unit->isOk()) {
    // On recupère l'erreur
    $unit = $unit->unwrap();
}

Ok mais c’est très verbeux comme approche !

Oui c’est une approche très verbeuse qui nous oblige à voir les erreurs et non plus simplement faire comme si elles étaient des effets exogènes au code.

C’est une surchage mentale sur le moment, une surcharge en apparence. Les plus gros défauts d’un programme ne sont jamais ceux induits par la machine mais par l’humain. Un langage qui contraint à être vigilant, c’est un problème plus robuste.

Quand je code, je souhaite que mon runtime, compiler ou lsp me dise quand j’oublie quelque chose. Je souhaite qu’il m’empêche de faire les erreurs qui coûtent en temps plus tard, parce que je ne veux pas me surcharger de ce travail plus tard. Me forcer à traiter les erreurs comme des valeurs (errors as values) et avoir un système de typage bloquant (sound type system) c’est la garantie d’une meilleure résilience une fois le programme en production.

Cette surcharge de verbosité est un gain de temps à long terme et plus de tranquilité en tant que développeur.

C’est une approche qui n’est évidemment pas parfaite. Il y a beaucoup à revoir et à améliorer. Néanmoins, j’aimerais beaucoup plus la voir.