phpDay 2019 - Handling Exceptional Conditions with Grace and Style

phpDay 2019 - Handling Exceptional Conditions with Grace and Style

Programmers naturally give more attention to a “happy path” - default scenario in application execution in which everything works as expected, therefore neglecting the opposite way things can go. Topics such as dealing with exceptional conditions, use of exceptions, error handling seem to have been insufficiently studied, and it is very difficult to find useful explanations and tutorials online.

This talk is an in-depth study about practices for dealing with exceptional conditions that promote code that is clean, consistent and convenient to work with. Special attention is given to applicable best practices for managing exceptions in a proper way, including formatting exception messages using named constructors, component-level exception type, exception wrapping.

To make the story complete, second part of the talk introduces a solution for establishing central error handling system that makes this critical aspect of the software stable, unambiguous and easy to maintain.

At the very end, some attention is given to testing exceptions and ways for keeping test code consistent and readable.

907a556f3e67367906a0863aa6f5d379?s=128

Nikola Poša

May 11, 2019
Tweet

Transcript

  1. HANDLING EXCEPTIONAL CONDITIONS HANDLING EXCEPTIONAL CONDITIONS WITH GRACE AND STYLE

    WITH GRACE AND STYLE Nikola Poša · @nikolaposa phpDay · 11 May 2019
  2. Sono contento di essere qui Sono contento di essere qui

  3. Sono contento di essere qui Sono contento di essere qui

    ABOUT ME ABOUT ME Software Architect specializing in PHP-based applications Lead Architect at Arbor Education Partners PHP Serbia Conference co-organizer  @nikolaposa  blog.nikolaposa.in.rs
  4. AGENDA AGENDA Approaches for dealing with exceptional conditions Set of

    applicable best practices for managing exceptions in a proper way Solution for establishing central error handling system Few tips for testing exceptions
  5. HAPPY PATH HAPPY PATH a.k.a. Normal Flow Happy path is

    a default scenario featuring no exceptional or error conditions, and comprises nothing if everything goes as expected. Wikipedia “
  6. $user = $userRepository->get('John'); if ($user->isSubscribedTo($notification)) { $notifier->notify($user, $notification); }

  7. $user = $userRepository->get('John'); if ($user->isSubscribedTo($notification)) { $notifier->notify($user, $notification); } Fatal

    error: Call to a member function isSubscribedTo() on null
  8. $user = $userRepository->get('John'); if (null !== $user && $user->isSubscribedTo($notification)) {

    $notifier->notify($user, $notification); }
  9. $user = $userRepository->get('John'); if (null !== $user && $user->isSubscribedTo($notification)) {

    $notifier->notify($user, $notification); } interface UserRepository { public function get(string $username): ?User; }
  10. DO NOT MESS WITH DO NOT MESS WITH NULL NULL

  11. When we return null, we are essentially creating work for

    ourselves and foisting problems upon our callers. Robert C. Martin, "Clean Code" “
  12. If you are tempted to return null from a method,

    consider throwing an exception or returning a Special Case object instead. Robert C. Martin, "Clean Code" “
  13. THROW MEANINGFUL EXCEPTION THROW MEANINGFUL EXCEPTION interface UserRepository { /**

    * @param string $username * @throws UserNotFound * @return User */ public function get(string $username): User; } final class DbUserRepository implements UserRepository { public function get(string $username): User { $userRecord = $this->db->fetchAssoc('SELECT * FROM users W if (false === $userRecord) { throw new UserNotFound(); } return User::fromArray($userRecord); } }
  14. interface UserRepository { @throws UserNotFound public function get(string $username): User;

    }
  15. try { $user = $userRepository->get($username); if ($user->isSubscribedTo($notification)) { $notifier->notify($user, $notification);

    } } catch (UserNotFound $ex) { $this->logger->notice('User was not found', ['username' => $u }
  16. try { $user = $userRepository->get($username); if ($user->isSubscribedTo($notification)) { $notifier->notify($user, $notification);

    } } catch (UserNotFound $ex) { $this->logger->notice('User was not found', ['username' => $u } try { $this->notifyUserIfSubscribed($username, $notification); } catch (\Throwable $ex) { $this->log($ex); }
  17. SPECIAL CASE SPECIAL CASE a.k.a. Null Object A subclass that

    provides special behavior for particular cases. Martin Fowler, "Patterns of Enterprise Application Architecture" “
  18. class UnknownUser extends User { public function username(): string {

    return 'unknown'; } public function isSubscribedTo(Notification $notification): b { return false; } }
  19. class UnknownUser extends User { public function username(): string {

    return 'unknown'; } public function isSubscribedTo(Notification $notification): b { return false; } } final class DbUserRepository implements UserRepository { public function get(string $username): User { $userRecord = $this->db->fetchAssoc('SELECT * FROM users W if (false === $userRecord) { return new UnknownUser(); } return User::fromArray($userRecord); } }
  20. $user = $userRepository->get('John'); if ($user->isSubscribedTo($notification)) { $notifier->notify($user, $notification); }

  21. Checking for Special Case

  22. Checking for Special Case if ($user instanceof UnknownUser) { //do

    something }
  23. Checking for Special Case if ($user instanceof UnknownUser) { //do

    something } if ($user === User::unknown()) { //do something }
  24. Special Case factory class User { public static function unknown():

    User { static $unknownUser = null; if (null === $unknownUser) { $unknownUser = new UnknownUser(); } return $unknownUser; } }
  25. Special Case object as private nested class class User {

    public static function unknown(): User { static $unknownUser = null; if (null === $unknownUser) { $unknownUser = new class extends User { public function username(): string { return 'unknown'; } public function isSubscribedTo(Notification $noti { return false; } }; } return $unknownUser; } }
  26. Returning null from methods is bad, but passing null into

    methods is worse. Robert C. Martin, "Clean Code" “
  27. class Order { public function __construct( Product $product, Customer $customer,

    ?Discount $discount ) { $this->product = $product; $this->customer = $customer; $this->discount = $discount; } } final class PremiumDiscount implements Discount { public function apply(float $productPrice): float { return $productPrice * 0.5; } }
  28. ?Discount $discount if (null !== $this->discount) { $price = $this->discount->apply($price);

    } class Order 1 { 2 public function __construct( 3 Product $product, 4 Customer $customer, 5 6 ) { 7 $this->product = $product; 8 $this->customer = $customer; 9 $this->discount = $discount; 10 } 11 12 public function total(): float 13 { 14 $price = $this->product->getPrice(); 15 16 17 18 19 20 return $price; 21 } 22 } 23
  29. Discount $discount class Order 1 { 2 public function __construct(

    3 Product $product, 4 Customer $customer, 5 6 ) { 7 $this->product = $product; 8 $this->customer = $customer; 9 $this->discount = $discount; 10 } 11 } 12
  30. Discount $discount class Order 1 { 2 public function __construct(

    3 Product $product, 4 Customer $customer, 5 6 ) { 7 $this->product = $product; 8 $this->customer = $customer; 9 $this->discount = $discount; 10 } 11 } 12 final class NoDiscount implements Discount { public function apply(float $productPrice): float { return $productPrice; } } $order = new Order($product, $customer, new NoDiscount());
  31. EXCEPTION VS SPECIAL CASE EXCEPTION VS SPECIAL CASE Special Case

    as default strategy instead of optional parameters Exceptions break normal ow to split business logic from error handling Special Case handles exceptional behaviour Exception emphasizes violated business rule
  32. USING EXCEPTIONS USING EXCEPTIONS

  33. Should be simple as: throw new \Exception('User was not found

    by username: ' . $usernam
  34. Should be simple as: throw new \Exception('User was not found

    by username: ' . $usernam
  35. CUSTOM EXCEPTION TYPES CUSTOM EXCEPTION TYPES bring semantics to your

    code emphasise exception type instead of exception message allow caller to act di erently based on Exception type
  36. STRUCTURING EXCEPTIONS STRUCTURING EXCEPTIONS src/ Todo/ Exception/ Model/ User/ Exception/

    Model/
  37. CREATING EXCEPTION CLASSES CREATING EXCEPTION CLASSES src/ User/ Exception/ InvalidUsername.php

    UsernameAlreadyTaken.php UserNotFound.php final class UserNotFound extends \Exception { }
  38. use App\User\Exception\UserNotFoundException; try { throw new UserNotFoundException(); } catch (UserNotFoundException

    $exception) { }
  39. use App\User\Exception\UserNotFoundException; try { throw new UserNotFoundException(); } catch (UserNotFoundException

    $exception) { } use App\User\Exception\UserNotFound; try { throw new UserNotFound(); } catch (UserNotFound $exception) { }
  40. COMPONENT LEVEL EXCEPTION COMPONENT LEVEL EXCEPTION TYPE TYPE

  41. COMPONENT LEVEL EXCEPTION COMPONENT LEVEL EXCEPTION TYPE TYPE namespace App\User\Exception;

    interface ExceptionInterface { } final class InvalidUsername extends \Exception implements ExceptionInterface { } final class UserNotFound extends \Exception implements ExceptionInterface { }
  42. use GuzzleHttp\Exception\ClientException; use GuzzleHttp\Exception\ServerException; use GuzzleHttp\Exception\GuzzleException; //marker interface try {

    //code that can emit exceptions } catch (ClientException $ex) { //... } catch (ServerException $ex) { //... } catch (GuzzleException $ex) { //... }
  43. FORMATTING EXCEPTION FORMATTING EXCEPTION MESSAGES MESSAGES

  44. FORMATTING EXCEPTION FORMATTING EXCEPTION MESSAGES MESSAGES throw new UserNotFound(sprintf( 'User

    was not found by username: %s', $username ));
  45. FORMATTING EXCEPTION FORMATTING EXCEPTION MESSAGES MESSAGES throw new UserNotFound(sprintf( 'User

    was not found by username: %s', $username )); throw new InsufficientPermissions(sprintf( 'You do not have permission to %s %s with the id: %s', $privilege, get_class($entity), $entity->getId() ));
  46. Encapsulate formatting into Exception classes final class UserNotFound extends \Exception

    implements ExceptionI { public static function byUsername(string $username): self { return new self(sprintf( 'User was not found by username: %s', $username )); } }
  47. Named Constructors communicate the intent throw UserNotFound::byUsername($username);

  48. Named Constructors communicate the intent throw UserNotFound::byUsername($username); throw TodoNotOpen::triedToSetDeadline($deadline, $this->status);

    throw TodoNotOpen::triedToMarkAsCompleted($this->status);
  49. PROVIDE CONTEXT PROVIDE CONTEXT

  50. PROVIDE CONTEXT PROVIDE CONTEXT final class UserNotFound extends \Exception implements

    ExceptionI { private $username; public static function byUsername(string $username): self { $ex = new self(sprintf('User was not found by username: % $ex->username = $username; return $ex; } public function username(): string { return $this->username; } }
  51. EXCEPTION WRAPPING EXCEPTION WRAPPING

  52. EXCEPTION WRAPPING EXCEPTION WRAPPING try { return $this->toResult( $this->httpClient->request('GET', '/users')

    ); } catch (ConnectException $ex) { throw ApiNotAvailable::reason($ex); }
  53. EXCEPTION WRAPPING EXCEPTION WRAPPING try { return $this->toResult( $this->httpClient->request('GET', '/users')

    ); } catch (ConnectException $ex) { throw ApiNotAvailable::reason($ex); } final class ApiNotAvailable extends \Exception implements Exceptio { public static function reason(ConnectException $error): self { return new self( 'API is not available', 0, $error //preserve previous error ); } }
  54. RETROSPECT RETROSPECT 1. create custom, cohesive Exception types 2. introduce

    component-level exception type 3. use Named Constructors to encapsulate message formatting and express the intent 4. capture & provide the context of the exceptional condition 5. apply exception wrapping to rethrow more informative exception
  55. ERROR HANDLING ERROR HANDLING

  56. WHEN TO CATCH EXCEPTIONS? WHEN TO CATCH EXCEPTIONS?

  57. WHEN TO CATCH EXCEPTIONS? WHEN TO CATCH EXCEPTIONS? Do NOT

    catch exceptions unless you have a very good reason
  58. CENTRAL ERROR HANDLER CENTRAL ERROR HANDLER Wraps the entire system

    to handle any uncaught exceptions from a single place
  59. CHALLENGES CHALLENGES user experience security logging

  60. CHALLENGES CHALLENGES user experience security logging adaptability

  61. EXISTING SOLUTIONS EXISTING SOLUTIONS

  62. EXISTING SOLUTIONS EXISTING SOLUTIONS - stack-based error handling, pretty error

    page, handlers for di erent response formats (JSON, XML) Whoops
  63. EXISTING SOLUTIONS EXISTING SOLUTIONS - stack-based error handling, pretty error

    page, handlers for di erent response formats (JSON, XML) - di erent formatting strategies (HTML, JSON, CLI), logging handler, non- blocking errors Whoops BooBoo
  64. Using Whoops final class ErrorHandlerFactory { public function __invoke(ContainerInterface $container)

    { $whoops = new \Whoops\Run(); if (\Whoops\Util\Misc::isAjaxRequest()) { $whoops->pushHandler(new JsonResponseHandler()); } elseif (\Whoops\Util\Misc::isCommandLine()) { $whoops->pushHandler(new CommandLineHandler()); } else { $whoops->pushHandler(new PrettyPageHandler()); } $whoops->pushHandler(new SetHttpStatusCodeHandler()); $whoops->pushHandler(new LogHandler($container->get('Logg return $whoops; } }
  65. src/bootstrap.php public/index.php bin/app //... initialize DI container $container->get(\Whoops\Run::class)->register(); return $container;

    $container = require __DIR__ . '/../src/bootstrap.php'; $container->get('App\Web')->run(); $container = require __DIR__ . '/../src/bootstrap.php'; $container->get('App\Console')->run();
  66. Logging errors final class LogHandler extends Handler { public function

    handle() { $error = $this->getException(); if ($error instanceof DontLog) { return self::DONE; } $this->logger->error($error->getMessage(), [ 'exception' => $error, ]); return self::DONE; } }
  67. final class UserNotFound extends \Exception implements ExceptionInterface, DontLog { //...

    }
  68. Setting HTTP status code final class SetHttpStatusCodeHandler extends Handler {

    public function handle() { $error = $this->getException(); $httpStatusCode = ($error instanceof ProvidesHttpStatusCode) ? $error->getHttpStatusCode() : 500; $this->getRun()->sendHttpCode($httpStatusCode); return self::DONE; } }
  69. interface ProvidesHttpStatusCode { public function getHttpStatusCode(): int; } final class

    UserNotFound extends \Exception implements ExceptionInterface, DontLog, ProvidesHttpStatusCode { //... public function getHttpStatusCode(): int { return 404; } }
  70. The OCP (Open­Closed Principle) is one of the driving forces

    behind the architecture of systems. The goal is to make the system easy to extend without incurring a high impact of change. Robert C. Martin, "Clean Architecture" “
  71. TEST EXCEPTIONAL TEST EXCEPTIONAL BEHAVIOUR BEHAVIOUR a.k.a. Negative Testing

  72. TESTING EXCEPTIONS WITH TESTING EXCEPTIONS WITH PHPUNIT PHPUNIT class TodoTest

    extends TestCase { /** * @test */ public function it_throws_exception_on_reopening_if_incomplet { $todo = Todo::from('Book flights', TodoStatus::OPEN()); $this->expectException(CannotReopenTodo::class); $todo->reopen(); } }
  73. ARRANGE-ACT-ASSERT ARRANGE-ACT-ASSERT 1. initialize SUT/prepare inputs 2. perform action 3.

    verify outcomes
  74. class TodoTest extends TestCase { /** * @test */ public

    function it_gets_completed() { $todo = Todo::from('Book flights', TodoStatus::OPEN()); $todo->complete(); $this->assertTrue($todo->isCompleted()); } }
  75. /** * @test */ public function it_throws_exception_on_reopening_if_incomplete() { $todo =

    Todo::from('Book flights', TodoStatus::OPEN()); try { $todo->reopen(); $this->fail('Exception should have been raised'); } catch (CannotReopenTodo $ex) { $this->assertSame( 'Tried to reopen todo, but it is not completed.', $ex->getMessage() ); } }
  76. Thank you Thank you Drop me some feedback and make

    this presentation better · @nikolaposa blog.nikolaposa.in.rs