In this post I will explain how we debugged and fixed random ruby JSON::ParserErrors caused by Rack::Attack throttles reading from StringIO without rewinding.
TL;DR: If you’re getting empty strings when reading Rack::Request bodies, consider using request.rewind
to ensure the cursor is in the correct position.
For a while, when running jortt locally, i noticed that whenever an error was thrown, the log would always show another error too:
16:07:33 web.1 | ERROR, 20250922 16:07:33-114: JSON::ParserError: unexpected token at ''
16:07:33 web.1 | …/ruby/.../gems/json-***/lib/json/common.rb:216:in `parse'
16:07:33 web.1 | …/ruby/.../gems/json-***/lib/json/common.rb:216:in `parse'
16:07:33 web.1 | …/code/jortt-app/config/initializers/rack_attack.rb:84:in `block in <top (required)>'
To start off, the line number corresponds with one of our Rack::Attack throttles that we initialize on startup.
Simply put, a throttle is a function provided by the rack-attack gem which takes an arbitrary name as well as an optional limit and period, plus a block that either returns a value or nothing. Rack::Attack calls this block the discriminator.
The discriminator differentiates sources from which similair requests might come: When a value is returned, it will monitor incoming requests and group them by whatever value your discriminator returns. Finally, when one of the groups reaches the set limit during the set period, rack will catch the request and make sure it does not enter our system, thus reducing the risk of DDoS-like attacks. Below the specific code that was causing the error:
# config/initializers/rack_attack.rb
Rack::Attack.throttle('mails/organization/hour', limit: 10, period: 1.hour) do |req|
if req.path.include?('/execute_command') && OrganizationRecord.current_set? &&
OrganizationRecord.current_organization.gratis?
—-> command_payload = JSON.parse(req.body.read)
req.body.rewind
next nil if command_payload.blank?
command_name = command_payload.dig('command', 'command_name')
next nil if command_name.blank?
command_class = Sequent::Core::Commands.find(command_name)
next nil if command_class.blank?
if [
SendInvoices, SendInvoiceByEmail, RemindInvoiceByEmail, SendEstimateByEmail
].include?(command_class)
OrganizationRecord.current
end
end
rescue StandardError => e
StaticLogHelper.log_to_console_in_dev_and_spec(e)
nil
end
GPT pointed out that req.body is a stringIO object, which has the method read
. This class, in turn, uses one cursor among many calls to read the buffer. This cursor needs to be moved back manually by calling rewind
on the stringIO object.
Could it be that we weren't properly handling stringIO reads? Where we not resetting the cursor after some reads, leading to JSON::ParserErrors? Let’s try:
Rack::Attack.throttle('mails/organization/hour', limit: 10, period: 1.hour) do |req|
if req.path.include?('/execute_command') && OrganizationRecord.current_set? &&
OrganizationRecord.current_organization.gratis?
puts({pos: req.body.pos})
…
At this point, the bug was obvious: the cursor was sitting at 3281 instead of 0;
16:03:37 web.1 | {:pos=>3281} <— NOT AT ZERO!!!
16:03:37 web.1 | ERROR, 20250922 16:03:37-380: JSON::ParserError: unexpected token at ''
While if I did req.body.rewind
:
# config/initializers/rack_attack.rb
Rack::Attack.throttle('mails/organization/hour', limit: 10, period: 1.hour) do |req|
if req.path.include?('/execute_command') && OrganizationRecord.current_set? &&
OrganizationRecord.current_organization.gratis?
req.body.rewind
puts({pos: req.body.pos})
…
Indeed the cursor was where it should be, preventing the error from occurring:
16:03:37 web.1 | {:pos=>0} <— now it is zero
… (no json error; req.body.read returns the correct string)
...So adding the rewind fixes the issue, but what caused the cursor to move in the first place?
It turns out we are indeed reading request bodies from multiple places in jortt, without using a clear strategy to ensure encapsulation of the IO’s cursor state:
➜ jortt-app git:(master) grep -R "body.read" --include="*.rb" -l
./.../sinatra/base_controller.rb
./.../webhooks_controller.rb
./.../storage_controller.rb
...
Sometimes we would rewind before a read:
def request_body
rack_request.body.rewind
rack_request.body.read
end
Other times, not at all:
post '/dropbox/?' do
Sequent
.command_service
.execute_commands(
AcceptWebhookMessage.new(
user_id: UserRoles::WEBHOOK,
message_id: Sequent.new_uuid,
provider: WebhookProvider::DROPBOX,
payload: request.body.read,
),
)
status 200
end
These differences meant the same req.body StringIO cursor could be left mid-stream, causing JSON::ParserError in downstream consumers like our throttle. Before I continued, I wondered why stringIO was leaking its state in the first place, adding this hassle of rewinding...
It turns out that having a movable cursor is helpful for the following reasons:
However, none of the reasons mentioned above apply to our implementations so far. In fact, our error shows that for us, it posed a risk of having a cursor stuck in the middle of some StringIO, breaking random parts of the app.
To take away some of this risk, I added a small helper that abstracts away reading and rewinding StringIOs (or any rewindable buffer), and used it wherever we read the StringIO directly before:
# infra/buffer_reader.rb
module Jortt
module BufferReader
def safe_read(readable)
fail "#{readable.class} is not rewindable" unless readable.respond_to?(:rewind)
readable.rewind
content = readable.read
readable.rewind
content
end
end
end
Rack::Attack.throttle('push/org/hour', limit: 10, period: 1.hour) do |req|
if req.path.include?('/execute_command') && OrganizationRecord.current_set? &&
OrganizationRecord.current_organization.gratis? # rubocop:disable Layout/LineLength
body = safe_read(req.body)
command_payload = JSON.parse(body)
These changes indeed solved the random JSON errors that we were getting in the beginning.
The JSON errors we kept seeing alongside unrelated application errors weren’t mysterious at all—they were a side-effect of treating req.body as if it were an immutable string instead of the stringIO it actually is. Because multiple parts of Jortt read from that same stream without a consistent rewind strategy, the cursor was often left mid-stream, causing later consumers (like the Rack::Attack throttle) to parse an empty string.
By recognizing that the movable cursor is a feature for true streaming scenarios—but a liability in our case—we introduced a simple BufferReader helper that always rewinds before and after reading. This change not only eliminated the spurious JSON parse errors, but also gave us a uniform interface for safe body access, reducing the risk of similar bugs creeping in elsewhere.