Class: Clickwrap::Services::AuthorizeExternalAction

Inherits:
Object
  • Object
show all
Defined in:
lib/clickwrap/services/authorize_external_action.rb

Overview

Clickwrap.authorize_external_action! — the outbox.

=========================================================================== READ THIS BEFORE CHANGING ANYTHING HERE.

This is a DISTRIBUTED RELIABILITY PROTOCOL. It is NOT a cross-system ACID transaction, and no amount of care in this file could make it one.

Stripe, an identity service, a timestamp authority, a remote signature provider: none of them can enlist in your database transaction. There is no two-phase commit here, no compensating rollback that reaches into someone else's ledger, and no moment at which "the evidence committed" and "the provider acted" are known to be true together. Clickwrap never claims atomicity across two independent systems, and the receipt this produces does not claim it either.

What this protocol actually gives you is narrower and achievable:

1. ONE local transaction commits the evidence event and a `pending`
 outbox row carrying a server-generated idempotency key.
2. The provider is called OUTSIDE that transaction, by the host, with
 that key — so a retry reaches the provider as the same request rather
 than as a second one.
3. The outcome is appended back idempotently through
 `record_provider_success_and_consume!`, `record_provider_failure!`,
 or `record_provider_outcome_unknown!`.
4. `unknown` stays `unknown` until someone or something resolves it.

Step 4 is the part people delete first and regret longest. A timeout is not a failure — it is an absence of information. Writing "failed" because the socket closed is how a second debit happens; writing "succeeded" because the retry returned 200 is how a fictional one does. The reconciliation task exists precisely so the ambiguous case can be settled later with better information than we have at the moment it occurs.

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(policy:, provider_name: nil, after_pending_action_is_saved_inside_transaction: nil, **capture_options) ⇒ AuthorizeExternalAction

Returns a new instance of AuthorizeExternalAction.

Raises:

  • (ArgumentError)


54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
# File 'lib/clickwrap/services/authorize_external_action.rb', line 54

def initialize(policy:, provider_name: nil,
               after_pending_action_is_saved_inside_transaction: nil,
               **capture_options)
  @policy = policy
  @provider_name = provider_name&.to_s
  @after_pending_action_is_saved_inside_transaction =
    after_pending_action_is_saved_inside_transaction
  @capture_options = capture_options

  return if @after_pending_action_is_saved_inside_transaction.nil? ||
            @after_pending_action_is_saved_inside_transaction.respond_to?(:call)

  raise ArgumentError,
        "after_pending_action_is_saved_inside_transaction must be callable. It receives " \
        "pending_action: and pending_receipt: as keyword arguments."
end

Instance Attribute Details

#after_pending_action_is_saved_inside_transactionObject (readonly)

Returns the value of attribute after_pending_action_is_saved_inside_transaction.



71
72
73
# File 'lib/clickwrap/services/authorize_external_action.rb', line 71

def after_pending_action_is_saved_inside_transaction
  @after_pending_action_is_saved_inside_transaction
end

#capture_optionsObject (readonly)

Returns the value of attribute capture_options.



71
72
73
# File 'lib/clickwrap/services/authorize_external_action.rb', line 71

def capture_options
  @capture_options
end

#policyObject (readonly)

Returns the value of attribute policy.



71
72
73
# File 'lib/clickwrap/services/authorize_external_action.rb', line 71

def policy
  @policy
end

#provider_nameObject (readonly)

Returns the value of attribute provider_name.



71
72
73
# File 'lib/clickwrap/services/authorize_external_action.rb', line 71

def provider_name
  @provider_name
end

Class Method Details

.idempotency_key_for(policy_key:, event_id:) ⇒ Object

The key handed to the provider. It is derived from the committed event id, which means one evidence event maps to exactly one external action forever: a retried capture that replays onto the same event reuses this key rather than minting a second one, and a provider that honors idempotency keys will therefore not act twice.

It is server-generated. A client-supplied idempotency key would let a browser decide whether a second debit is a duplicate — which is exactly the decision the server exists to make.



50
51
52
# File 'lib/clickwrap/services/authorize_external_action.rb', line 50

def self.idempotency_key_for(policy_key:, event_id:)
  "clickwrap-#{policy_key}-#{event_id}"
end

Instance Method Details

#callObject

Captures the evidence and commits the pending outbox row in ONE local transaction, then returns the ExternalAction so the caller can hand its id and idempotency key to a job.

The provider call belongs after this method returns, never inside it. A transaction held open across someone else's network is a transaction holding locks on evidence rows while waiting for a stranger's DNS.



81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
# File 'lib/clickwrap/services/authorize_external_action.rb', line 81

def call
  SchemaRequirements.require!(:external_actions)

  action = nil

  receipt = Capture.new(
    policy: policy,
    **capture_options,
    consume_one_time_authorizations: false,
    record_protected_outcome: false
  ).capture_and! do |pending|
    action = create_pending_action!(pending)
  end

  # A replayed capture returns the original receipt without re-running the
  # block, so the outbox row for that event already exists. Finding it is
  # the correct answer: the same authorization, with the same key, not a
  # second one that could produce a second provider call.
  action ||= ExternalAction.find_by(event_id: receipt.event_id)

  unless action
    raise ExternalActionError,
          "The evidence for #{policy.key} committed as event #{receipt.event_id}, but no " \
          "pending external action was found for it. Do not call the provider: without a " \
          "committed outbox row there is nothing to resolve the outcome against."
  end

  action.reload
end