PHP fwdays '19 - Handling Exceptional Conditions with Grace and Style

PHP fwdays '19 - 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

June 01, 2019
Tweet

Transcript

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

    WITH GRACE AND STYLE Nikola Poša · @nikolaposa
  2. Я радий бути тут Я радий бути тут

  3. Я радий бути тут Я радий бути тут 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); } 1

    2 3 4 5
  7. $user = $userRepository->get('John'); if ($user->isSubscribedTo($notification)) { $notifier->notify($user, $notification); } 1

    2 3 4 5 if ($user->isSubscribedTo($notification)) { Fatal error: Call to a member function isSubscribedTo() on null $user = $userRepository->get('John'); 1 2 3 $notifier->notify($user, $notification); 4 } 5 6 7 8
  8. $user = $userRepository->get('John'); if ($user->isSubscribedTo($notification)) { $notifier->notify($user, $notification); } 1

    2 3 4 5 $user = $userRepository->get('John'); if (null !== $user && $user->isSubscribedTo($notification)) { $notifier->notify($user, $notification); } 1 2 3 4 5
  9. Null checks ood if (null !== $user) { if (null

    !== $todo) { if (null !== $notification) { $user = $userRepository->get('John'); 1 2 3 $todo = $todoRepository->get('Book flights'); 4 5 6 $notification = TodoReminder::from($todo, $user); 7 8 9 if ($user->isSubscribedTo($notification)) { 10 $notifier->notify($user, $notification); 11 } 12 } 13 } 14 } 15
  10. Vague Interface interface UserRepository { public function get(string $username): ?User;

    }
  11. Vague Interface interface UserRepository { public function get(string $username): ?User;

    } interface UserRepository { /** * @param UserId $id * @return User|bool User instance or boolean false if User w */ public function get(string $username); }
  12. DO NOT MESS WITH DO NOT MESS WITH NULL NULL

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

    ourselves and foisting problems upon our callers. Robert C. Martin, "Clean Code" “
  14. 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" “
  15. THROW EXCEPTION THROW EXCEPTION interface UserRepository * @throws UserNotFound public

    function get(string $username): User; 1 { 2 /** 3 * @param string $username 4 5 * @return User 6 */ 7 8 } 9 throw new UserNotFound(); final class DbUserRepository implements UserRepository 1 { 2 public function get(string $username): User 3 { 4 $userRecord = $this->db->fetchAssoc('SELECT * FROM use 5 6 if (false === $userRecord) { 7 8 } 9 10 return User::fromArray($userRecord); 11 } 12 } 13
  16. interface UserRepository { @throws UserNotFound public function get(string $username): User;

    }
  17. 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 }
  18. 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); }
  19. 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" “
  20. class UnknownUser extends User { public function username(): string {

    return 'unknown'; } public function isSubscribedTo(Notification $notification): b { return false; } } return new UnknownUser(); final class DbUserRepository implements UserRepository 1 { 2 public function get(string $username): User 3 { 4 $userRecord = $this->db->fetchAssoc('SELECT * FROM use 5 6 if (false === $userRecord) { 7 8 } 9 10 return User::fromArray($userRecord); 11 } 12 } 13
  21. $user = $userRepository->get('John'); if ($user->isSubscribedTo($notification)) { $notifier->notify($user, $notification); }

  22. Checking for Special Case

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

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

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

    User { static $unknownUser = null; if (null === $unknownUser) { $unknownUser = new UnknownUser(); } return $unknownUser; } }
  26. 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; } }
  27. Returning null from methods is bad, but passing null into

    methods is worse. Robert C. Martin, "Clean Code" “
  28. 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; } }
  29. ?Discount $discount if (null !== $this->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 public function total(): float 13 { 14 $price = $this->product->getPrice(); 15 16 17 $price = $this->discount->apply($price); 18 19 20 return $price; 21 } 22 } 23
  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
  31. 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());
  32. 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
  33. USING EXCEPTIONS USING EXCEPTIONS

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

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

    by username: ' . $usernam
  36. 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
  37. STRUCTURING EXCEPTIONS STRUCTURING EXCEPTIONS src/ Todo/ Exception/ Model/ User/ Exception/

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

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

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

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

  42. 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 { }
  43. 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) { //... }
  44. FORMATTING EXCEPTION FORMATTING EXCEPTION MESSAGES MESSAGES

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

    was not found by username: %s', $username ));
  46. 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() ));
  47. 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 )); } }
  48. Named Constructors communicate the intent throw UserNotFound::byUsername($username);

  49. Coherent exceptional conditions throw TodoNotOpen::triedToSetDeadline($deadline, $this->status); throw TodoNotOpen::triedToMarkAsCompleted($this->status);

  50. PROVIDE CONTEXT PROVIDE CONTEXT

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

    ExceptionI { private string $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; } }
  52. EXCEPTION WRAPPING EXCEPTION WRAPPING

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

    ); } catch (ConnectException $ex) { throw ApiNotAvailable::reason($ex); } 1 2 3 4 5 6 7
  54. EXCEPTION WRAPPING EXCEPTION WRAPPING try { return $this->toResult( $this->httpClient->request('GET', '/users')

    ); } catch (ConnectException $ex) { throw ApiNotAvailable::reason($ex); } 1 2 3 4 5 6 7 final class ApiNotAvailable extends \Exception implements Exce { public static function reason(ConnectException $error): se { return new self( 'API is not available', 0, $error //preserve previous error ); } } 1 2 3 4 5 6 7 8 9 10 11
  55. EXCEPTION WRAPPING EXCEPTION WRAPPING try { return $this->toResult( $this->httpClient->request('GET', '/users')

    ); } catch (ConnectException $ex) { throw ApiNotAvailable::reason($ex); } 1 2 3 4 5 6 7 final class ApiNotAvailable extends \Exception implements Exce { public static function reason(ConnectException $error): se { return new self( 'API is not available', 0, $error //preserve previous error ); } } 1 2 3 4 5 6 7 8 9 10 11 $error //preserve previous error final class ApiNotAvailable extends \Exception implements Exce 1 { 2 public static function reason(ConnectException $error): se 3 { 4 return new self( 5 'API is not available', 6 0, 7 8 ); 9 } 10 } 11
  56. 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
  57. ERROR HANDLING ERROR HANDLING

  58. WHEN TO CATCH EXCEPTIONS? WHEN TO CATCH EXCEPTIONS?

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

    catch exceptions unless you can handle the problem so that the application continues to work
  60. CENTRAL ERROR HANDLER CENTRAL ERROR HANDLER Wraps the entire system

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

  62. CHALLENGES CHALLENGES user experience security logging adaptability

  63. EXISTING SOLUTIONS EXISTING SOLUTIONS

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

    page, handlers for di erent response formats (JSON, XML) Whoops
  65. 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
  66. 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; } }
  67. 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();
  68. 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; } }
  69. final class UserNotFound extends \Exception implements ExceptionInterface, DontLog { //...

    }
  70. 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; } }
  71. interface ProvidesHttpStatusCode { public function getHttpStatusCode(): int; } final class

    UserNotFound extends \Exception implements ExceptionInterface, DontLog, ProvidesHttpStatusCode { //... public function getHttpStatusCode(): int { return 404; } }
  72. 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" “
  73. TEST EXCEPTIONAL TEST EXCEPTIONAL BEHAVIOUR BEHAVIOUR a.k.a. Negative Testing

  74. TESTING EXCEPTIONS WITH TESTING EXCEPTIONS WITH PHPUNIT PHPUNIT class TodoTest

    extends TestCase { /** * @test */ public function it_throws_exception_on_reopening_if_incomp { $todo = Todo::from('Book flights', TodoStatus::OPEN()) $this->expectException(CannotReopenTodo::class); $todo->reopen(); } } 1 2 3 4 5 6 7 8 9 10 11 12 13 14
  75. TESTING EXCEPTIONS WITH TESTING EXCEPTIONS WITH PHPUNIT PHPUNIT class TodoTest

    extends TestCase { /** * @test */ public function it_throws_exception_on_reopening_if_incomp { $todo = Todo::from('Book flights', TodoStatus::OPEN()) $this->expectException(CannotReopenTodo::class); $todo->reopen(); } } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 $this->expectException(CannotReopenTodo::class); $todo->reopen(); class TodoTest extends TestCase 1 { 2 /** 3 * @test 4 */ 5 public function it_throws_exception_on_reopening_if_incomp 6 { 7 $todo = Todo::from('Book flights', TodoStatus::OPEN()) 8 9 10 11 12 } 13 } 14
  76. ARRANGE-ACT-ASSERT ARRANGE-ACT-ASSERT 1. initialize SUT/prepare inputs 2. perform action 3.

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

    function it_gets_completed() { $todo = Todo::from('Book flights', TodoStatus::OPEN()); $todo->complete(); $this->assertTrue($todo->isCompleted()); } }
  78. /** * @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() ); } }
  79. Thank you Thank you Drop me some feedback and make

    this presentation better · joind.in/talk/8a8d6 @nikolaposa blog.nikolaposa.in.rs