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, stderr: nil) ⇒ Failure

Every field is sanitized, not only the message. All of them arrive from the wire on the client side, so all of them 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 rest left the same poisoned row the scrub exists to prevent, reachable through a different key.



25
26
27
28
29
30
31
32
33
34
35
36
37
# File 'lib/hot_cell/failure.rb', line 25

def initialize(code:, permanent: nil, message: nil, error_class: nil, cause: nil, signal: nil,
               stderr: 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)

  # The tail, because this is a transcript and its last line is the one that ended the request. Every
  # other field is one message, where the head is what matters.
  @stderr = self.class.sanitize(stderr, keep: :tail)
  @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

#stderrObject (readonly)

Returns the value of attribute stderr.



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

def stderr
  @stderr
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.



63
64
65
66
67
68
69
# File 'lib/hot_cell/failure.rb', line 63

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.



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

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], stderr: wire[:stderr]
end

.one_line(text) ⇒ Object

For a message that is about to be written as one line of a log. sanitize leaves CR and LF alone, which is right for a message an application stores or re-raises, but a peer that puts a newline in one writes a second log line of its own — formatted and indented like the real ones. Escape them, and the rest of the control characters with them.



92
93
94
# File 'lib/hot_cell/failure.rb', line 92

def one_line(text)
  sanitize(text)&.gsub(/[[:cntrl:]]/) { |character| character.dump[1..-2] }
end

.sanitize(message, keep: :head) ⇒ Object

keep: :tail is for a captured stream rather than a message, and it is load-bearing on from_wire: a cell this client does not trust can fill that field to the response limit, and head-truncating there hands the caller the noise a decoder printed first instead of the fatal that ended it.



99
100
101
102
103
104
105
106
107
108
109
110
# File 'lib/hot_cell/failure.rb', line 99

def sanitize(message, keep: :head)
  return nil if message.nil?

  text = String(message).dup.force_encoding(Encoding::UTF_8).scrub("")
  text = if keep == :tail && text.bytesize > MAX_MESSAGE_BYTES
    text.byteslice(text.bytesize - MAX_MESSAGE_BYTES, MAX_MESSAGE_BYTES)
  else
    text.byteslice(0, MAX_MESSAGE_BYTES)
  end

  text.scrub("")
end

Instance Method Details

#permanent?Boolean

Returns:

  • (Boolean)


39
40
41
# File 'lib/hot_cell/failure.rb', line 39

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.



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

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

#to_sObject

one_line rather than raw interpolation: to_s becomes the exception message an application logs, and a transcript ends in a newline — so a peer that put newlines in one writes extra lines into that log. The attribute keeps the raw text.



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

def to_s
  text = [ code, cause, error_class, message ].compact.join(": ")
  stderr ? "#{text} (#{self.class.one_line(stderr)})" : text
end