Déclenchement et Gestion exceptions : quoi en penser ?

a marqué ce sujet comme résolu.

Bonjour à tous !

Sur un projet perso que je suis en train de développer pendant mon chômage, je viens d’établir une connexion HTTP REST à une API Web accessible via une bearer authentication. Comme je veux bien faire les choses, je souhaite massivement utiliser les exceptions PHP pour gérer tout ça (chose que je n’ai jamais vue faite dans aucune des deux boîtes dans lesquelles j’ai travaillées, et pourtant j’en ai lu du code ===> ça fait de moi un débutant en la matière car même à la fac, c’était pas une compétence particulièrement explorée).

Je voudrais donc avoir votre avis sur la manière dont j’ai géré les exceptions qui peuvent avoir lieu. Pour ce faire, je vais vous montrer du code et l’expliquer en même temps.

Si vous avez des remarques à faire (exemple : c’est améliorable parce que …, ou bien : c’est faux parce que …), je vous invite à m’en faire part car c’est là le sujet du topic. :D

Framework PHP utilisé : Laravel v9. Cependant, ça ne devrait aucunement poser problème si vous ne connaissez pas. Seul 1 ou 2 points mineurs pourraient vous échapper, sauf que je les explique à côté du code.

Edit

Une version à jour du code et un peu différente de ce qui suit ici est disponible : https://zestedesavoir.com/forums/sujet/16378/declenchement-et-gestion-exceptions-quoi-en-penser/#p244376 .

Trait PHP qui éventuellement déclenche une exception

Explications sur la logique des sources

Ce Trait a pour but d’être appelé dès que l’on veut requêter l’API Web REST (Akeneo, un PIM catalogue de produits que l’on peut requêter pour récupérer les 100 premiers produits ou en ajouter un, par exemple). Ce Trait permet donc d’établir une connexion avec l’API Web REST (Akeneo) en fournissant les données d’authentification Bearer. La gestion du rafraîchissement de token est aussi gérée (l' access_token est enregistré dans un fichier dont le nom est le timestamp à l’écriture de ce dernier, si 3000s au moins se sont écoulées entre l’exécution du Trait et donc de la requête que l’on souhaite faire vers Akeneo et le timestamp "nom du fichier", alors on lance une requête de rafraîchissement de token et l’on remplace les fichiers access_token et refresh_token - une autre façon de faire aurait été de mettre ça dans un script de crontab mais bref on s’en fiche).

90% du code du Trait permet la gestion du rafraîchissement du token comme vous pourrez le lire.

Sources

trait AkeneoConnector {

	function sendAkeneoRequest($request_uri, $data = NULL) {

		if(empty(Storage::files('akeneo_access_token'))) {
			$queried_tokens = Http::withBasicAuth(config('akeneo.authentication.client_id'), config('akeneo.authentication.secret'))->post(config('akeneo.authentication.endpoint'), [
				"grant_type" => config('akeneo.authentication.grant_type'),
				"username" => config('akeneo.authentication.username'),
				"password" => config('akeneo.authentication.password')
			])->throw();

		} elseif(now()->diffInSeconds(Carbon::createFromTimestamp(pathinfo(Storage::files('akeneo_access_token')[0])['filename'])) > 3000) {
			$queried_tokens = Http::withBasicAuth(config('akeneo.authentication.client_id'), config('akeneo.authentication.secret'))->post(config('akeneo.authentication.endpoint'), [
				"grant_type" => config('akeneo.authentication.grant_type_refresh'),
				"refresh_token" => Storage::get(Storage::files('akeneo_refresh_token')[0]),
			])->throw();

		}

		if(isset($queried_tokens)) {
			$now = now();
			
			$queried_tokens_as_object = $queried_tokens->object();
			Storage::deleteDirectory('akeneo_access_token');
			Storage::put('akeneo_access_token/' . $now->timestamp, $queried_tokens_as_object->access_token);
	
			Storage::deleteDirectory('akeneo_refresh_token');
			Storage::put('akeneo_refresh_token/' . $now->timestamp, $queried_tokens_as_object->refresh_token);
		}

		if(empty(Storage::files('akeneo_access_token'))) {
			throw new AkeneoAccessTokenNotFoundException();
		}

		if(empty(Storage::files('akeneo_refresh_token'))) {
			throw new AkeneoRefreshTokenNotFoundException();
		}

		if(now()->diffInSeconds(Carbon::createFromTimestamp(pathinfo(Storage::files('akeneo_access_token')[0])['filename'])) > 3000) {
			throw new AkeneoAccessTokenHasExpiredException();
		}

		return Http::withToken(Storage::get(Storage::files('akeneo_access_token')[0]))->get($request_uri, $data)->throw();
	}
	
}

Déclenchements potentiels d’exceptions

Voici les endroits qui pourraient déclencher une exception :

  • La fonction Laravel ->throw() qui est appelée sur le résultat des deux requêtes post d’authentification (dont une de rafraîchissement) au tout début du code : donc si une exception HTTP Client ou HTTP Server (codes HTTP 400 ou 500 respectivement) doit être déclenchée au niveau de l’authentification ou au niveau du rafraîchissement, et celle appelée sur le résultat de la requête get qui est retournée par ce Trait : si une exception HTTP Client ou HTTP Server doit être déclenchée au niveau de la requête que le script appelant ce Trait doit effectuer.

  • La fonction Laravel Storage::put doit également déclencher une exception si besoin car j’ai configuré Laravel FlyStorage pour ce faire : ça arriverait en cas de problème d’écriture des fichiers tokens (pour le access_token comme pour le refresh_token).

  • Evidemment, mes exceptions custom du genre throw new AkeneoAccessTokenNotFoundException (j’aurais peut-être pu en rajouter d’autres mais c’est déjà pas mal et je ne suis même pas sûr que ce soit si utile que ça).

Code appelant ce Trait

Explications sur la logique

Je fais donc ici appel à mon Trait défini ci-dessus : ainsi je souhaite requêter Akeneo et gérer toutes les exceptions.

Explications sur la gestion des exceptions (catch)

Je pars du principe que toutes les exceptions doivent être logguées dans mon serveur Laravel : d’où l’appel à la fonction Laravel report().

Je pars du principe, également, que toutes les exceptions doivent être portées à la connaissance de l’utilisateur car toute exception implique l’impossibilité de requêter l’API REST Akeneo sur demande de l’utilisateur, donc il faut bien le mettre au courant.

  • Cependant, il n’est pertinent, me semble-t-il, d’adresser à l’utilisateur un message précis que pour une partie de ces exceptions parce qu’elles seraient liées à l’utilisateur : en l’occurrence, \Illuminate\Http\Client\RequestException (problème au traitement par Akeneo d’une requête d’authentification/rafraîchissement token/de la vraie requête demandée au Trait) si et seulement si on a du code HTTP d’erreur 403, 404 et 429 (l’utilisateur est impliqué dans ce problème, d’où un message très en relation avec l’Exception). Si le code HTTP est différent, c’est qu’il y a un problème côté serveur ou avec mon code, l’utilisateur n’est donc pas impliqué, donc je lui affiche un message plus généraliste, moins en relation avec l’Exception.

  • C’est aussi le cas pour toute exception de type \Illuminate\Http\Client\ConnectionException (problème non pas au traitement par Akeneo de la requête, mais bien en amont : par exemple IP d’Akeneo injoignable).

  • Enfin, j’adresse à l’utilisateur un message ENCORE PLUS généraliste pour toute exception de type \League\Flysystem\UnableToWriteFile | \Exception $e (qui correspondent à une impossibilité d’écriture d’au moins un fichier token, ou à toute autre exception à laquelle je n’aurais pas pensée à catcher).

function test() {

	try {

		$response = $this->sendAkeneoRequest(config('akeneo.connections.rest_api.endpoint') . '/products', [
			'pagination_type' => 'search_after',
			'limit' => 100
		]);
		

	}
	catch(\Illuminate\Http\Client\RequestException | \Illuminate\Http\Client\ConnectionException $e) {
		
		if($e instanceof \Illuminate\Http\Client\RequestException) {
			switch($e->response['code']) {
				case 403:
					echo __('akeneo.errors.forbidden');
					break;
				case 404:
					echo __('akeneo.errors.not_found');
					break;
				case 429:
					echo __('akeneo.errors.too_many_requests');
					break;
				default:
					echo __('akeneo.errors.connection_problem');
					break;
			}

		} else {
			echo __('akeneo.errors.connection_problem');
		}

		report($e);

	}
	catch(\League\Flysystem\UnableToWriteFile | \Exception $e) {
		echo __('akeneo.errors.unable_to_query_unknown_reason');
		report($e);
	}

}

Autre question, qui concerne cette façon de gérer les exceptions (catch)

A la fac on m’a toujours appris de faire les catch au niveau du script appelant la fonctionnalité qui peut déclencher les exceptions. C’est donc bien ce que j’ai fait ici.

Sauf qu’on se retrouve avec beaucoup de lignes de code de catch, avec un switch dedans, un if, etc. C’est non seulement peu lisible, mais de plus barbant à écrire à chaque fois que je souhaite faire appel à mon Trait (donc à chaque fois que je souhaite requêter Akeneo, par exemple pour récupérer la liste des produits comme ici).

Comment puis-je me délester de ce poids ?


Je finirais ce topic en vous adressant un grand merci pour le fait de donner votre avis et pour l’aide que vous m’apporterez ! :D

Bonne journée à tous !

+0 -0

Pour rappel, habituellement, je code en C, alors les codes gérés avec des exceptions, c’est pas ma spécialité, néanmoins, je trouve ton code étrangement redondant:

dans ton test, tu fais un sendAkeneoRequest, et tout le reste, les 3/4 du code, c’est de la gestion d’erreur, pour finalement dire que, quelle que soit l’exception, tu fais un echo et un report. Pourquoi ne pas attraper des classes génériques d’erreurs (à priori, ici \Illuminate\Http\Client et \League\Flysystem) et n’écrire qu’une fois que, si tu as une exception, et bien tu

echo $e->describe();
report($e);

En imaginant évidemment que tes exceptions ont une méthode générique pour se décrire, mais le contraire serait étonnant.

Tu dis

Si le code HTTP est différent, c’est qu’il y a un problème côté serveur ou avec mon code, l’utilisateur n’est donc pas impliqué, donc je lui affiche un message plus généraliste, moins en relation avec l’Exception.

Si tu es ici, c’est que tu ne sais pas gérer le problème, est ce que ça a vraiment du sens de limiter l’information à laquelle l’utilisateur a accès. Là encore, l’UX, c’est pas mon domaine, mais tu choisis juste de dire "ça marche pas" au lieu de dire "ça ne marche pas à cause de <description incomprehensible par l’utilisateur lambda>", est ce que c’est censé rendre la chose plus agréable pour lui ? Si tu veux être plus sympa avec l’utilisateur, tu peux faire un système pour l’orienter dans la résolution en fonction du problème rencontré, mais ce n’est pas à cet endroit dans le code que tu vas gérer ça. Ici, il n’y a aucune exception que tu peux rattraper directement, il n’y a donc pas de traitement spécifique à avoir.

Tout le code que tu écrit ici, c’est du code que tu dois écrire à chaque fois que tu écrits un sendAkeneoRequest, tu as intérêt à ce qu’il soit le plus simple possible, tu ne veux pas avoir un nouveau cas qui te forces à mettre à jours tous les appelants dans toute la base de code.

Ce que tu essaies de faire m’a l’air très compliqué pour le service rendu.

Déjà, tu sembles partir du principe que la méthode qui peut échouer (ici sendAkeneoRequest) peut lancer une palanquée d’exceptions différentes pour tous les cas possibles. C’est un comportement intéressant dans le cadre d’une bibliothèque, où tu ne sais pas à priori ce qui va être fait de la méthode ni ce dont tu auras besoin spécifiquement, mais pas forcément dans le cadre d’un programme unique. Dans ce cas, tu peux te contenter de rajouter les exceptions nécessaires au besoin.

De plus, même si tu pars sur une gestion fine des erreurs via des exceptions, tu n’es pas obligé de créer un type par problème – et si c’est le cas, il faut que la hiérarchie des types d’exceptions soit cohérente, avec un type chapeau qui permet de tout gérer de façon identique si nécessaire sans avoir à écrire des catch(ExceptionA | ExceptionB | ExceptionC | ExceptionD… ). Je ne connais pas spécifiquement PHP, mais j’imagine que comme dans les autres langages à exceptions, celles-ci sont des classes normales, qui peuvent donc avoir des attributs. Ça peut être mis à contribution pour éviter de multiplier les types.

D’autre part, je ne connais pas la structure générale de ton programme, mais ça me semble étrange de définir les messages d’erreurs pour l’utilisateur au moment de chaque appel de ta fonction. D’ici ça ressemble à un couplage fort et inutile.

Ce qu’on fait d’habitude, c’est plutôt quelque chose comme : (catch(Exception technique) -> Exception métier) puis, le plus haut possible dans le programme, (catch(Exception métier) -> comportement spécifique pour l’utilisateur).

En particulier, je rejoins Jacen sur le fait qu’il ne faut pas noyer l’utilisateur avec des détails technique. Une bonne pratique, c’est de donner un message centré sur l’utilisateur à l’utilisateur (« Ça n’a pas fonctionné, voici ce que vous pouvez faire… ») avec éventuellement un code d’erreur à remonter au support si c’est pertinent ; et de logguer les raisons techniques dans un fichier de log.

D’une manière générale : KISS – et ses petits frères, Worse is better et YAGNI.

Enfin, il y a quelque chose dans ces deux lignes qui aurait du t’interpeller au moment où tu les a écrites :

catch(\Illuminate\Http\Client\RequestException | \Illuminate\Http\Client\ConnectionException $e) {
		
    if($e instanceof \Illuminate\Http\Client\RequestException) {

Bonjour tout le monde, merci beaucoup pour vos réponses.

Après lecture de vos deux posts, je résume ici les problèmes soulevés et tente d’y apporter une réponse, ou de poser des questions :

  • Trop de catch ( @jacen et @SpaceFox).

    • Vos recommandations :
      • Remplacer la diversité d’exceptions catchées par seulement une petite poignée de classes d’exceptions génériques et mettre cela dans un seul catch.
      • Utiliser les attributs de classes d’exceptions pour stocker un message qui change d’une exception à une autre afin d’éviter d’avoir une hiérarchie d’exception. (@SpaceFox spécifiquement).
    • Ma décision : j’ai lu dans des articles de blog il y a quelques années que la gestion des exceptions doit être la plus fine possible, mais je n’exclue pas avoir compris cela de travers. Pour autant, j’ai choisi, avant d’appliquer votre recommandation, de partir sur une autre piste : j’expose cette dernière à la fin de ce post (partie "Nouvelle façon de définir les catch"). Je souhaiterais avant de partir sur votre reco, de connaître votre avis quant à cette piste (à mon avis vous allez dire que c’est trop compliqué mais pas sûr).
  • tu choisis juste de dire "ça marche pas" au lieu de dire "ça ne marche pas à cause de <description incomprehensible par l’utilisateur lambda>", est ce que c’est censé rendre la chose plus agréable pour lui ? Si tu veux être plus sympa avec l’utilisateur, tu peux faire un système pour l’orienter dans la résolution en fonction du problème rencontré, mais ce n’est pas à cet endroit dans le code que tu vas gérer ça. Ici, il n’y a aucune exception que tu peux rattraper directement, il n’y a donc pas de traitement spécifique à avoir. (@jacen)

    • Question : Mmh à quel endroit ça se ferait ça ? Par ailleurs, je comprends bien ce que tu me fais remarquer, mais je ne vois pas ce que tu me recommandes de faire pour le coup ?

    • Les messages des exceptions associées aux codes HTTP 403, 404 et 429 sont justement très compréhensibles par l’utilisateur et directement en lien avec lui, d’où cette finesse de gestion. Les autres messages d’erreur (akeneo.errors.connection_problem et akeneo.errors.unable_to_query_unknown_reason) équivalent effectivement à du "Cela ne marche pas"). En aucun cas je n’affiche d’erreur incompréhensible pour l’utilisateur. Et je loggue bien dans mon système interne Laravel les exceptions techniques (backstack incluse).

  • "Dans ce cas, tu peux te contenter de rajouter les exceptions nécessaires au besoin." (@SpaceFox) : dans le Trait, les exceptions que je gère sont uniquement des problèmes de requêtage Akeneo, incluant l’authentification nécessaire pour requêter ce dernier (incluant l’exception du problème d’écriture des tokens d’accès à Akeneo). Ce sont bien des exceptions nécessaires.

    • Pour ce point, je ne pense pas devoir modifier mon approche ni mon code donc.
  • "D’autre part, je ne connais pas la structure générale de ton programme, mais ça me semble étrange de définir les messages d’erreurs pour l’utilisateur au moment de chaque appel de ta fonction. D’ici ça ressemble à un couplage fort et inutile. Ce qu’on fait d’habitude, c’est plutôt quelque chose comme : (catch(Exception technique) -> Exception métier) puis, le plus haut possible dans le programme, (catch(Exception métier) -> comportement spécifique pour l’utilisateur)." (@SpaceFox) .

    • La doc Laravel sous-entend qu’on peut afficher une exception à l’utilisateur : concept de "rendu d’exception" ("rendering") évoqué ici https://laravel.com/docs/9.x/errors . Bien sûr, en plus d’afficher l’erreur à l’utilisateur, je la logue dans mon système de logs Laravel (fonction Laravel report). En tout cas c’est quelque chose que j’avais vu à la fac mais je me trompe peut-être.

    • Questions : qu’est-ce qu’une exception "métier" ? L’exception technique est ce qu’on loggue côté Laravel, ok. "comportement spécifique pour l’utilisateur" ça je pense que c’est un message expliquant à l’utilisateur quoi faire ?


D’une manière générale : KISS – et ses petits frères, Worse is better et YAGNI.

OK je ne connaissais absolument pas, merci @SpaceFox je vais prendre connaissance tout de suite de ces documents.

Enfin, il y a quelque chose dans ces deux lignes qui aurait du t’interpeller au moment où tu les a écrites :

catch(\Illuminate\Http\Client\RequestException | \Illuminate\Http\Client\ConnectionException $e) {

if($e instanceof \Illuminate\Http\Client\RequestException) {

Le fait que toutes deux héritent de HttpClientException ? A la lecture de ces 2 lignes je ne vois pas. J’aurais pu faire un catch pour chacune de ces exceptions ?


Nouvelle façon de définir les catch

Au lieu, dans le script appelant, de devoir définir tous ces catch, j’ai fait en sorte de les définir dans mon Trait. Le script appelant se confie alors à mon Trait au sein d’une closure passée au Trait et exécuté à l’intérieur du try, selon le concept de délégation de fonction.

Source ci-jointe.

Script appelant, sans devoir mettre les catch

function test() {

	$response = $this->getFromAkeneo(config('akeneo.connections.rest_api.endpoint') . '/products', [
		'pagination_type' => 'search_after',
		'limit' => 100

	], function($query_result) {

		do {

			$response_as_object = $query_result->object();
			var_dump($response_as_object->_links);
			
		} while(
			property_exists($response_as_object->_links, 'next') && $query_result = $this->getFromAkeneo($response_as_object->_links->next->href)
		);

	});

}

Script Trait, définissant les catch

private function authenticateForAkeneo() {
	if(empty(Storage::files('akeneo_access_token'))) {
		$queried_tokens = Http::withBasicAuth(config('akeneo.authentication.client_id'), config('akeneo.authentication.secret'))->post(config('akeneo.authentication.endpoint'), [
			"grant_type" => config('akeneo.authentication.grant_type'),
			"username" => config('akeneo.authentication.username'),
			"password" => config('akeneo.authentication.password')
		])->throw();

	} elseif(now()->diffInSeconds(Carbon::createFromTimestamp(pathinfo(Storage::files('akeneo_access_token')[0])['filename'])) > 3000) {
		$queried_tokens = Http::withBasicAuth(config('akeneo.authentication.client_id'), config('akeneo.authentication.secret'))->post(config('akeneo.authentication.endpoint'), [
			"grant_type" => config('akeneo.authentication.grant_type_refresh'),
			"refresh_token" => Storage::get(Storage::files('akeneo_refresh_token')[0]),
		])->throw();

	}

	if(isset($queried_tokens)) {
		$now = now();
		
		$queried_tokens_as_object = $queried_tokens->object();
		Storage::deleteDirectory('akeneo_access_token');
		Storage::put('akeneo_access_token/' . $now->timestamp, $queried_tokens_as_object->access_token);

		Storage::deleteDirectory('akeneo_refresh_token');
		Storage::put('akeneo_refresh_token/' . $now->timestamp, $queried_tokens_as_object->refresh_token);
	}
}

function getFromAkeneo($request_uri, $data = NULL, $delegated_function = NULL) {

	$query_result = NULL;

	try {
		$this->authenticateForAkeneo();

		$query_result = Http::withToken(Storage::get(Storage::files('akeneo_access_token')[0]))->get($request_uri, $data)->throw();

		if(isset($delegated_function)) {
			$delegated_function($query_result);
		}

	}
	catch(\Illuminate\Http\Client\RequestException | \Illuminate\Http\Client\ConnectionException $e) {
		if($e instanceof \Illuminate\Http\Client\RequestException) {
			switch($e->response->status()) {
				case 403:
					echo __('akeneo.errors.forbidden');
					break;
				case 404:
					echo __('akeneo.errors.not_found');
					break;
				case 429:
					echo __('akeneo.errors.too_many_requests');
					break;
			}
		}
		
		echo __('akeneo.errors.connection_problem');
		report($e);

	}
	catch(\League\Flysystem\UnableToWriteFile | \Exception $e) {
		echo __('akeneo.errors.unable_to_query_unknown_reason');
		report($e);
	}

	return $query_result;
}

Inconvénients à l’exécution du code

  • Un confrère prenant connaissance de ce code et souhaitant faire un script appelant le Trait pourrait penser que la fonction d script appelant confiée au Trait est une callback étant exécutée dans un contexte supposément asynchrone lors du requêtage HTTP par le client HTTP Laravel dans le Trait. Or, il n’en est rien : le contexte est synchrone. C’est uniquement de la délégation de fonction que je fais-là.

  • (pas vraiment un inconvénient) La définition du try / catch / catch a lieu une première fois à l’appel de $this->getFromAkeneo par le script appelant (juste avant, donc en-dehors, de la closure déléguée), puis à nouveau, autant de nombre de fois qu’il y a d’itérations dans la boucle du script appelant, toujours par le script appelant (dans la closure déléguée) : while( property_exists($response_as_object->_links, 'next') && $query_result = $this->getFromAkeneo($response_as_object->_links->next->href) ).

+0 -0
Connectez-vous pour pouvoir poster un message.
Connexion

Pas encore membre ?

Créez un compte en une minute pour profiter pleinement de toutes les fonctionnalités de Zeste de Savoir. Ici, tout est gratuit et sans publicité.
Créer un compte