All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.gfccollective.concurrent.FutureBuilder.scala Maven / Gradle / Ivy

The newest version!
package org.gfccollective.concurrent

import org.gfccollective.concurrent.FutureBuilder.PartialFunctionToExceptionExtractor
import org.gfccollective.concurrent.trace.{FutureGenericErrorTrace, FutureRecoverableErrorTrace, FutureTrace}
import org.gfccollective.logging.OpenLoggable

import scala.concurrent._
import scala.concurrent.duration._
import scala.util.control.NonFatal
import scala.util.{Failure, Success, Try}

/**
 * A convenience wrapper around Future-based calls.
 *
 * Typically we have an API that returns some Future[OpResult] and we need to set an operation timeout,
 * possibly provide a default value, possibly log how long it took for debugging, maybe retry.
 *
 * By default it builds a Future[Try[A]] from Future[A].
 * When default value is provided it builds Future[A] from Future[A].
 * At the very minimum it'll apply a mandatory timeout but you can add retry logic, call tracer, etc.
 *
 * It is important to specify type A _at_the_time_of_creation_, type inference doesn't pick it up.
 * {{{
 *   FutureBuilder[Option[Foo]]("FooCall")......
 * }}}
 *
 * We want to apply timeouts to *all* Futures, so,
 * {{{
 *   ...futureBuilder.runWithTimeout(100 milliseconds){ .... something that returns Future ... }
 * }}}
 *
 */
case class FutureBuilder[A,R] private (
  callName: String // this'll show up in wrapped exceptions and logs

, additionalMessage: String // this'll show up in wrapped exceptions and logs

// N.B. below 'by name' parameters are to support 'retry' functionality

// How we process errors changes depending on whether or not there's a default
// value. When there is one we want to stay with A as a Result type,
// i.e. Future[A] => Future[A]
// OTOH, when we don't have any defaults - we want to bubble that decision
// up to a typesystem level. Scala doesn't have checked exceptions but
// we can express a mandatory check with a Try[A] type.
, errorHandler: (=> Future[Try[A]]) => Future[R]

// As it happens some of the exceptions are actually data,
// APIs that generate them expect users to implement some control flow around them.
// In those cases we need to pass them 'un-wrapped'.
// Implementing this as a partial function also gives an opportunity to 'map' them
// to some other type of exception if needed.
, passThroughExceptionHandler: PartialFunction[Throwable, Throwable]

// Allows us to retry calls when we bump into errors, but see passThroughExceptionHandler.
, addNumRetries: FutureBuilder[_,_] => (=> Future[Try[A]]) => Future[Try[A]]

// Allows us to log service call times, to debug site problems.
// All of this async stuff is notorious for not having comprehensible stack traces.
, addTraceCalls: (=> Future[Try[A]]) => Future[Try[A]]

// Puts a timeout on call Future.
, addSingleCallTimeout: (=> Future[Try[A]]) => Future[Try[A]]

) {

  /** Composes all the Future transformations, gives resulting Future back.
    * This is the only 'run' function here, so, effectively we insist on timeouts for all futures.
    *
    * By default NonFatal failures are caught and represented as a Try[A].
    * OTOH if a serviceErrorDefaultValue is provided than we log errors and default to that, result type remains A.
    *
    * @param after mandatory timeout we set on 'service call' Futures
    * @param call 'by name' parameter that evaluates to a Future[SomeServiceCallResult],
    *             this may be called multiple times if retry() is enabled.
    *
    * @return Future[SomeServiceCallResult] if a default value is provided or
    *         Future[Try[SomeServiceCallResult]] in case there's no default,
    *         a 'checked exception' of sorts.
    */
  def runWithTimeout( after: FiniteDuration
                      )( call: => Future[A]
                      ): Future[R] = {
    import ScalaFutures._

    this.copy[A,R](addSingleCallTimeout = { f =>
      implicit val executor: ExecutionContext = ExecutionContext.Implicits.global
      f.withTimeout(after, Some(s"Timed out in: $callName ($additionalMessage)"))
    }).run(call)
  }

  /** Will return this value if a call fails or we fail to interpret results. */
  def withServiceErrorDefaultValue( v: A )
                                  : FutureBuilder[A,A] = {

    implicit val executor: ExecutionContext = ExecutionContext.Implicits.global

    copy(errorHandler = { f =>
      f recover {

        // Un-expected exceptions
        case NonFatal(e) =>
          blocking{ FutureBuilder.Logger.error(s"${callName}(${additionalMessage}): ${e.getMessage}", e) }
          Success(v) // we have a default value to fill in in case of generic server errors

      } map {

        case Failure(e) => throw e // 'pass through' exception, need to re-throw
        case Success(v) => v // normal service call result
      }
    })
  }

  /** Will retry this many times before giving up and returning an error. */
  def retry( n: Int )
           ( implicit ec: ExecutionContext )
           : FutureBuilder[A,R] = {
    require(n>0, "Num retries must be > 0")
    copy( addNumRetries = b => { f =>
      import scala.language.implicitConversions
      implicit def log(t: Throwable): Unit = { if (b.passThroughExceptionHandler.isDefinedAt(t)) b.passThroughExceptionHandler(t) }
      ScalaFutures.retry(n)(f)
    } )
  }

  /** Unfortunately, some exceptions are 'data' and are important for control flow, in their unmodified/unwrapped form,
    * this allows caller to enumerate them and even (optionally) transform to another exception type.
    *
    * When we get one of these we don't want to retry() service calls, we don't want to wrap them with additional
    * user message, we just want to pass them through.
    */
  def withPassThroughExceptions( handlePassThroughExceptions: PartialFunction[Throwable, Throwable]
                               ): FutureBuilder[A, R] = {
    copy(passThroughExceptionHandler = handlePassThroughExceptions)
  }


  /** Enables call tracing via provided callback function.
    * E.g. you can log call times or send metrics somewhere.
    *
    * @param callTracer will be called with the results of a call.
    */
  def withTraceCalls( callTracer: (FutureTrace) => Unit
                    ): FutureBuilder[A,R] = {

    copy(addTraceCalls = { f =>
      implicit val executor: ExecutionContext = ExecutionContext.Implicits.global

      val beginTime = System.currentTimeMillis

      val fRef = f // grab result of a 'by name' function call, onComplete is Unit type, this avoids firing call twice

      fRef onComplete { res: Try[Try[A]] =>
        val endTime = System.currentTimeMillis
        val dt = endTime - beginTime

        val trace = res match {
          case Success(Success(_)) => FutureTrace(callName, additionalMessage, dt, None)
          case Success(Failure(e)) => FutureTrace(callName, additionalMessage, dt, Some(FutureRecoverableErrorTrace(e)))
          case Failure(e)      => FutureTrace(callName, additionalMessage, dt, Some(FutureGenericErrorTrace(e)))
        }

        blocking {
          try {
            callTracer.apply(trace)
          } catch {
            case NonFatal(e) =>
              FutureBuilder.Logger.error(s"Call tracer failed: ${e.getMessage}", e)
          }
        }
      }

      fRef
    })
  }


  /** We hide this method to force runWithTimeout() instead, this way timeouts are always applied. */
  private
  def run( call: => Future[A]
         ): Future[R] = {

    // order matters, e.g. we want to apply single call timeout before any retries
    errorHandler(
      addNumRetries(this)(
        addTraceCalls(
          addSingleCallTimeout(
            addErrorMessage(
              callService(call
    ))))))
  }



  /** Adds more context to thrown exceptions. */
  private[this]
  def addErrorMessage(f: => Future[Try[A]]
                     ): Future[Try[A]] = {
    implicit val ec = SameThreadExecutionContext

    f transform (
      s = identity,
      f = (t: Throwable) => new Exception(s"${callName}(${additionalMessage}): ${t.getMessage}", t)
    )
  }


  private[this]
  def callService( call: => Future[A]
                 ): Future[Try[A]] = {

    val PassThroughExceptionExtractor = PartialFunctionToExceptionExtractor(passThroughExceptionHandler)

    implicit val gec = ExecutionContext.Implicits.global
    val stec = org.gfccollective.concurrent.SameThreadExecutionContext

    // Recover from pass-through exceptions here.
    //
    // There are 3 cases:
    // - no errors -> Success(result)
    // - exception that we need to pass through -> Failure(exception)
    // - generic errors -> leave alone, let them bubble up to retry logic and be wrapped with additional user message
    //
    // In other words, return values and exceptions that are 'data' get converted to result of the future, via Try,
    // so, both cases are treated as 'data' and produce 'a value'
    //
    call.map(Success(_))(stec) recover {
      case PassThroughExceptionExtractor(e) => Failure(e)
    }
  }
}

object FutureBuilder {

  /** Constructs FutureBuilder.
    *
    * @param callName name of the future-based call, shows up in the logs and traces
    * @param additionalMessage some interesting identifying data, if any, e.g. user guid
    * @tparam A type the result
    */
  def apply[A]( callName: String
              , additionalMessage: String = ""
              ): FutureBuilder[A, Try[A]] = {

    new FutureBuilder[A, Try[A]](
      callName                    = callName
    , additionalMessage           = additionalMessage
    , errorHandler                = defaultErrorHandler[A] _
    , passThroughExceptionHandler = PartialFunction.empty
    , addNumRetries               = _ => byNameId[A] _
    , addTraceCalls               = byNameId[A] _
    , addSingleCallTimeout        = byNameId[A] _
    )
  }


  private
  val Logger = new AnyRef with OpenLoggable

  private // there's a subtle difference here from built-in 'identity' function in that this is a 'by-name' call
  def byNameId[A](f: => Future[Try[A]]
                 ): Future[Try[A]] = {
    f
  }


  private
  def defaultErrorHandler[A](f: => Future[Try[A]]
                            ): Future[Try[A]] = {

    implicit val ec = org.gfccollective.concurrent.SameThreadExecutionContext

    // 'pass through' exceptions are already captured as a value here,
    // catch the rest and turn everything into a value (except for fatal exceptions)

    f recover {
      case NonFatal(e) => Failure(e)
    }
  }


  /** This works similar to .filter.map(), i.e. for exceptions that we want to pass through
   *  (the ones for which a partial function is defined) we apply it, thus getting .map() behavior
   *
   *  Exceptions that are not explicitly handled here are 'generic IO exceptions' eligible for
   *  retry and eligible to be wrapped with additional user message.
   *
   *  OTOH the ones that match we need to preserve 'as is', no retries, should be passed
   *  to caller unmodified.
   */
  case class PartialFunctionToExceptionExtractor (
    passThroughExceptionHandler: PartialFunction[Throwable, Throwable]
  ) {

    def unapply(e: Throwable
               ): Option[Throwable] = {
      passThroughExceptionHandler.lift(e)
    }
  }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy