Class: HotCell::Failure

Inherits:
Object
  • Object
show all
Defined in:
lib/hot_cell/failure.rb

Overview

A cell's verdict on a request that did not succeed.

The message is untrusted and it outlives the request. It comes out of a worker that has just parsed a hostile file, and Vips::Error#message routinely contains the input filename. Applications store these as durable blob metadata so they can re-decide later against a newer library, which means an unscrubbed byte sequence becomes a permanently poisoned row, and an invalid UTF-8 sequence makes a downstream regex raise ArgumentError instead of answering false.

So the message is capped and scrubbed here, and that is not only hygiene: a cell that could not serialize its own error could not answer at all. The client scrubs again on receipt, because JSON.parse is not a filter — a \uD800 escape parses into an invalid UTF-8 String without complaint.

Constant Summary collapse

MAX_MESSAGE_BYTES =
512

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(code:, permanent: nil, message: nil, error_class: nil, cause: nil, signal: nil) ⇒ Failure

Every field is sanitized, not only the message. All five arrive from the wire on the client side, so all five carry whatever the peer put there — and they travel further than the message does, into to_s, into the perform.hot_cell event, and into whatever a subscriber writes down. code in particular is the field applications store. Scrubbing one and not the other four left the same poisoned row the scrub exists to prevent, reachable through a different key.



25
26
27
28
29
30
31
32
# File 'lib/hot_cell/failure.rb', line 25

def initialize(code:, permanent: nil, message: nil, error_class: nil, cause: nil, signal: nil)
  @code = self.class.sanitize(code).to_s
  @cause = self.class.sanitize(cause)
  @signal = self.class.sanitize(signal)
  @error_class = self.class.sanitize(error_class)
  @message = self.class.sanitize(message)
  @permanent = permanent.nil? ? Codes.permanent?(@code, cause: @cause) : permanent
end

Instance Attribute Details

#causeObject (readonly)

Returns the value of attribute cause.



18
19
20
# File 'lib/hot_cell/failure.rb', line 18

def cause
  @cause
end

#codeObject (readonly)

Returns the value of attribute code.



18
19
20
# File 'lib/hot_cell/failure.rb', line 18

def code
  @code
end

#error_classObject (readonly)

Returns the value of attribute error_class.



18
19
20
# File 'lib/hot_cell/failure.rb', line 18

def error_class
  @error_class
end

#messageObject (readonly)

Returns the value of attribute message.



18
19
20
# File 'lib/hot_cell/failure.rb', line 18

def message
  @message
end

#signalObject (readonly)

Returns the value of attribute signal.



18
19
20
# File 'lib/hot_cell/failure.rb', line 18

def signal
  @signal
end

Class Method Details

.for(code, detail, cause: nil) ⇒ Object

Builds from either a message String or an Exception. An Exception has to become two wire fields, and that rule was written out at three call sites across two gems — the worker, the supervisor's control answer, and the client's transport.



54
55
56
57
58
59
60
# File 'lib/hot_cell/failure.rb', line 54

def for(code, detail, cause: nil)
  if detail.is_a?(Exception)
    new code: code, cause: cause, error_class: detail.class.name, message: detail.message
  else
    new code: code, cause: cause, message: detail
  end
end

.from_wire(wire) ⇒ Object

A code this client has never heard of is not permanent. An old client will meet a code added later, and the harm of the two mistakes is not symmetrical: retrying something permanent costs some work, while writing down a verdict that was temporary is irreversible. A permanent that is present but not a boolean is derived rather than believed. Truthiness would make any non-nil value permanent, and permanent is the answer that cannot be taken back — so a garbled field must not be able to say it.



68
69
70
71
72
73
74
75
76
77
# File 'lib/hot_cell/failure.rb', line 68

def from_wire(wire)
  permanent = if [ true, false ].include?(wire[:permanent])
    wire[:permanent]
  else
    Codes.known?(wire[:code]) && Codes.permanent?(wire[:code], cause: wire[:cause])
  end

  new code: wire[:code], permanent: permanent, cause: wire[:cause], signal: wire[:signal],
      error_class: wire[:class], message: wire[:message]
end

.sanitize(message) ⇒ Object



79
80
81
82
83
84
# File 'lib/hot_cell/failure.rb', line 79

def sanitize(message)
  return nil if message.nil?

  String(message).dup.force_encoding(Encoding::UTF_8)
    .scrub("").byteslice(0, MAX_MESSAGE_BYTES).scrub("")
end

Instance Method Details

#permanent?Boolean

Returns:

  • (Boolean)


34
35
36
# File 'lib/hot_cell/failure.rb', line 34

def permanent?
  @permanent
end

#to_hObject

compact rather than four guards: the constructor puts every one of these through &.to_s or sanitize, so each is a String or nil and there is no falsey-but-meaningful value to protect. permanent is the exception and survives, because compact drops only nil.



41
42
43
44
# File 'lib/hot_cell/failure.rb', line 41

def to_h
  { code: code, permanent: permanent? }
    .merge(cause: cause, signal: signal, class: error_class, message: message).compact
end

#to_sObject



46
47
48
# File 'lib/hot_cell/failure.rb', line 46

def to_s
  [ code, cause, error_class, message ].compact.join(": ")
end