exchange log: a write that fails raises
Logger's default rescues a failed write or rotation and prints a
warning to stderr, so a full disk would lose the record a refusal
alert points at without Sentry hearing of it. reraise_write_errors
makes the failure the request's, reported like any other.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kqo9sKAi4DnKCQkrtYoAwM
change
commit 36657285a10aa99103490473aee4f5f072b68435
author Claude <noreply@anthropic.com>
date
parent 65a3e4da
diff --git a/lib/pro_tacts/exchange_log.rb b/lib/pro_tacts/exchange_log.rb
index d162df3..1b00302 100644
--- a/lib/pro_tacts/exchange_log.rb
+++ b/lib/pro_tacts/exchange_log.rb
@@ -44,7 +44,10 @@ module ProTacts
 
     # Builds the Logger the middleware writes to: appended, unbuffered
     # (Logger syncs its own device), and rotated by size. path "stderr"
-    # writes to the process's stderr instead.
+    # writes to the process's stderr instead. A write or rotation that
+    # fails raises rather than taking Logger's default, a warning on
+    # stderr: the log is the record a refusal alert sends someone to,
+    # and one lost quietly would leave that alert pointing at nothing.
     #: (String | Pathname path) -> Logger
     def self.open_log(path)
       target = if path.to_s == "stderr"
@@ -54,7 +57,7 @@ module ProTacts
                  path.to_s
                end
 
-      Logger.new(target, ROTATIONS, ROTATE_AT).tap do |logger|
+      Logger.new(target, ROTATIONS, ROTATE_AT, reraise_write_errors: [StandardError]).tap do |logger|
         logger.level = :info
         logger.formatter = proc do |_severity, datetime, _progname, msg|
           stamp = datetime.strftime("%Y-%m-%dT%H:%M:%S.%3N")
diff --git a/sig/gems/logger.rbs b/sig/gems/logger.rbs
new file mode 100644
index 0000000..5af6a1a
--- /dev/null
+++ b/sig/gems/logger.rbs
@@ -0,0 +1,9 @@
+# RBS's stdlib signature for Logger predates the reraise_write_errors
+# keyword (logger 1.6.2), so this adds the constructor ExchangeLog
+# calls as an overload of the one it declares.
+#
+# Written against logger 1.7.0 (lib/logger.rb).
+class Logger
+  def initialize: (logdev? logdev, ?Numeric | String shift_age, ?Integer shift_size, ?shift_period_suffix: String, ?binmode: boolish, ?datetime_format: String, ?formatter: _Formatter, ?progname: String, ?level: Integer | interned, ?reraise_write_errors: Array[Module], ?skip_header: boolish) -> void
+                | ...
+end
diff --git a/test/pro_tacts/test_exchange_log.rb b/test/pro_tacts/test_exchange_log.rb
index e8a44d8..13168a8 100644
--- a/test/pro_tacts/test_exchange_log.rb
+++ b/test/pro_tacts/test_exchange_log.rb
@@ -103,6 +103,17 @@ class ExchangeLogTest < Minitest::Test
     assert_includes logged, "!! RuntimeError: boom"
   end
 
+  # A log that cannot be written raises, where Logger's own default
+  # would print a warning and carry on with the record lost. The file
+  # is closed under the Logger holding it: a write to it raises
+  # IOError, as one to a full disk raises Errno::ENOSPC.
+  def test_a_log_that_will_not_write_raises
+    @everything = true
+    app.instance_variable_get(:@logger).instance_variable_get(:@logdev).dev.close
+
+    assert_raises(IOError) { request "/dav/addressbook/", method: "PROPFIND" }
+  end
+
   def test_the_request_is_logged_whole
     @stub.status = 404