Blog

Why Rack::Request's Body Returns an Empty String (and How to Fix It)

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.

The problem

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.

What is a throttle?

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

The culprit: StringIO

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?

Why this happens

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...

stringIO, y u do dis?

It turns out that having a movable cursor is helpful for the following reasons:

  • Streaming & chunked transfer: HTTP/1.1 can deliver bodies in chunks; consumers can read progressively as bytes arrive, which is exactly what an IO cursor models.
  • Memory efficiency: You avoid buffering large uploads (videos, multi-GB imports) in RAM; you read → handle → discard
  • Back-pressure / incremental parsing: Middleware or app code could read/readpartial/each and apply work per chunk (hashing, virus scan, CSV row parse, multipart handling) without ever holding the full body

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.

The solution

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.

In conclusion

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.

Foto Tristan

Tristan

Developer

Contact

Zilverline gebruikt cookies om content en advertenties te personaliseren en om ons websiteverkeer te analyseren. Ook delen we informatie over uw gebruik van onze site met onze partners voor adverteren en analyse. Deze partners kunnen deze gegevens combineren met andere informatie die u aan ze heeft verstrekt of die ze hebben verzameld op basis van uw gebruik van hun services.

Okee