Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Advanced handlers support, timeout #405

Open
wants to merge 18 commits into
base: main
Choose a base branch
from

Conversation

martonlederer
Copy link
Collaborator

@martonlederer martonlederer commented Nov 27, 2024

I found myself coming across many scenarios, when I need multiple features for a single handler and I need to implement that every time separately. This aims to solve the problem, while being 100% backwards compatible.

What this PR adds:

  • Adds support for handler timeouts
    This is great in situations, when you want to limit how long your handler can be used not with the times it can run, but with a deadline. After starting to work on this feature, I've found that this is something you want to implement as well in feat(handlers): refactor to include the ability to set a timeout #360
  • Adds support for self defined error handlers
    This basically makes sure that the process can handle errors with more freedom. For example, it allows developers to refund users, if an error is thrown in the handler. This allows them to use functions, such as assert() in the handler function, instead of having to do something like this, every time an error is thrown:
    local qty = tonumber(msg.Tags.Quantity)
    if qty == nil or qty > somevalue then
      msg.reply({
        Action = "Transfer",
        ["X-Action"] = "Refund",
        Recipient = msg.Tags.Sender,
        Quantity = msg.Tags.Quantity,
        ["Refund-Reason"] = "Invalid quantity"
      })
    end
    Example usage:
    Handlers.advanced({
      name = "example-error-handler",
      pattern = { Action = "Test" },
      handle = function (msg)
        ...
        local qty = tonumber(msg.Tags.Quantity)
        assert(qty ~= nil and qty <= somevalue, "Invalid quantity")
        ...
      end,
      errorHandler = function (msg, env, err)
        msg.reply({
          Action = "Transfer",
          ["X-Action"] = "Refund",
          Recipient = msg.Tags.Sender,
          Quantity = msg.Tags.Quantity,
          ["Refund-Reason"] = tostring(err)
        })
        return "break"
      end
    })
    Read the disclaimer about the errorHandler feature
  • Adds support for runType overwrites
    This allows the developer to overwrite the result of the pattern, if the pattern matches (continue or break). Also useful when the doesn't define the pattern with a function
  • Adds support for activating/deactivating handlers, without removing them (Handlers.setActive(name, status))
  • Adds support for onRemove(reason) hook for handlers to listen to handler removals
  • Adds complete input validation to handler operators

What I've changed:

  • Refactoring
  • Simplified handler operations by adding one function that can perform all of the existing handlers logic based on one config (Handlers.advanced(config))
  • Updated other handler operations to use the new advanced function
  • "Redirected" Handlers.append() to Handlers.add()
    I've found that these two functions are basically the same. Please let me know if I need to change this back.

Here is the schema used by the new Handlers.advanced() function's config param:

interface HandlerConfig {
  /** Handler name */
  name: string;
  /** The position where the handler will be added */
  position?: "append" | "prepend" | {
    /** Position type */
    type: "append" | "prepend" | "before" | "after";
    /** Position target, used for "before" and "after" */
    target?: string;
  };
  /** Pattern that determinates whether the handler runs or not */
  pattern?: table | function | string;
  /** Handler function or a resolver to add */
  handle: function | { [matchSpec: (table | function)]: function };
  /** 
   * If the pattern matches and this field is defined, it will overwrite whatever
   * the pattern match returns (break/continue).
   * - "break"/-1: Once the handler runs, no other handlers will be evaluated/executed
   * - "continue"/1: Once the handler runs, handler evaluation doesn't stop and moves
   *               on the next matching handler, if there is any
   */
  runType?: "break" | -1 | "continue" | 1;
  /** Maximum times this handler can run */
  maxRuns?: integer;
  /** 
   * Optional error handler for the handler. Having this makes the process run the handler
   * simularly to when a code block/function is wrapped in a try/catch block. The handler
   * runs no matter if it errors or not. If it does error, the error handler is executed
   * immediately after the error is thrown. 
   * By default if the pattern match for this handler returned "continue" (or 1) or 
   * "runType" is "continue", then the evalutation/execution of handlers will
   * continue to the next matching handler, if there is any.
   * This can be overriden by returning "break"/-1 or "continue"/1 from the error handler.
   */
  errorHandler?: function;
  /**
   * Optional hook for handler removal. Its only argument is to get the reason for the removal.
   * The reason can be:
   * - "user-remove": Handlers.remove() was called on the handler
   * - "timeout": The handler timed out (see the timeout feature below)
   * - "expired": The handler reached its "maxRuns"
   */
  onRemove?: function;
  /** Allows disabling the execution of a handler without removing it */
  inactive?: boolean;
  /**
   * Optional deadline, after which the handler expires and runs no longer.
   * Can be defined with milliseconds or blocks.
   * If the timeout value is an integer instead of a table, blocks will be used
   * as the default unit.
   */
  timeout?: integer | {
    /** Timeout units */
    type: "milliseconds" | "blocks";
    /** Timeout value, in the units of the defined type */
    value: integer;
  };
};

Disclaimer about the errorHandler

Since the errorHandler works by catching errors and not forwarding them to ao, the behavior of the handler changes when it is included. In my opinion, this disclaimer should be included in any documentation about the feature.
For instance, normally in cases like the one below, the global value SomeGlobal will not change to true, because the error "invalidates" the entire execution, so no state updates are made:

Handlers.add(
  "some-name",
  { Action = "Test" },
  function (msg)
    SomeGlobal = true
    error("throw an error")
  end
)

When the errorHandler is defined, the error will not invalidate the execution, it'll only be forwarded to the errorHandler, so SomeGlobal will change to true, even tho an error is thrown after:

Handlers.advanced({
  name = "example-error-handler",
  pattern = { Action = "Test" },
  handle = function (msg)
    SomeGlobal = true
    error("throw an error")
  end,
  errorHandler = function (msg, env, err)
    print("error was thrown")
  end
})

Two things can be done, to avoid this:

  1. Place any state altering code after all assertions/validations have been executed
Handlers.advanced({
  name = "example-error-handler",
  pattern = { Action = "Test" },
  handle = function (msg)
    error("throw an error")
    -- notice how this is after the error
    SomeGlobal = true
  end,
  errorHandler = function (msg, env, err)
    print("error was thrown")
  end
})
  1. Correct/reset the state changes in the error handler
Handlers.advanced({
  name = "example-error-handler",
  pattern = { Action = "Test" },
  handle = function (msg)
    SomeGlobal = true
    error("throw an error")
  end,
  errorHandler = function (msg, env, err)
    -- state change is reset
    SomeGlobal = false
    print("error was thrown")
  end
})

@martonlederer martonlederer marked this pull request as ready for review November 27, 2024 16:21
-- ensure the handler hasn't timed out yet
if o.timeout then
-- remove handler if it timed out
if (o.timeout.type == 'milliseconds' and o.timeout.value < msg.Timestamp) or (o.timeout.type == 'blocks' and o.timeout.value < msg["Block-Height"]) then
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the having the handler still run at the deadline and only get removed after is the most straightforward implementation/solution, but I'm happy to change it to:

if (o.timeout.type == 'milliseconds' and o.timeout.value <= msg.Timestamp) or (o.timeout.type == 'blocks' and o.timeout.value <= msg["Block-Height"]) then

@martonlederer
Copy link
Collaborator Author

A note: adding an ao.CurrentTimestamp field would allow relative timeouts, so the user doesn't have to create an advanced handler and manually calculate the timeout blocks/timestamp.

@martonlederer martonlederer changed the title Advanced handlers support Advanced handlers support, timeout Dec 9, 2024
This prevents firing the event twice
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant