Cached recalculates etag even if content did not change · Issue #13783 · playframework/playframework · GitHub
Skip to content

Cached recalculates etag even if content did not change #13783

Open

Description

@OlegYch

here is a test with desired behavior for play 3.0.10

package play.api.cache

import java.time.Instant
import java.util.concurrent.atomic.AtomicInteger
import scala.concurrent.duration.*
import scala.util.Random
import jakarta.inject.*
import play.api.cache.ehcache.EhCacheApi
import play.api.http
import play.api.mvc.*
import play.api.test.*
import play.api.Application

import scala.concurrent.Future

class CachedSpec2 extends PlaySpecification {
  sequential

  def cached(implicit app: Application) = {
    new Cached(app.injector.instanceOf[AsyncCacheApi])(using app.materializer)
  }

  // Tests here don't use the body
  val Action = ActionBuilder.ignoringBody

  "the cached action" should {
    "refresh only if content changed" in new WithApplication() {
      override def running() = {
        var content = "1"
        val action  = cached(using app)(_.uri, duration = 1)(Action(Results.Ok(content)))
        val resultA = action(FakeRequest("GET", "/a")).run()
        status(resultA) must_== 200
        contentAsString(resultA) must_== content

        def useOldEtag(from: Future[Result] = resultA) =
          action(FakeRequest("GET", "/a").withHeaders(IF_NONE_MATCH -> header(ETAG, from).get)).run()

        status(useOldEtag()) must_== NOT_MODIFIED
        Thread.sleep(1500) // sleep to expire cache
        val resultAA = useOldEtag() // old etag refreshes cache
        status(resultAA) must_== 200 // same content is returned on first request
        header(ETAG, resultAA).get must_== header(ETAG, resultA).get
        header(EXPIRES, resultAA).get must_!= header(EXPIRES, resultA).get // expires is updated
        val resultAAA = useOldEtag() // subsequent requests have same etag and expires
        status(resultAAA) must_== NOT_MODIFIED
        header(ETAG, resultAA).get must_== header(ETAG, resultAAA).get
        header(EXPIRES, resultAA).get must_== header(EXPIRES, resultAAA).get
        // but updating content invalidates the old etag
        content = "2"
        Thread.sleep(1500) // sleep to expire cache
        val resultB = useOldEtag()
        status(resultB) must_== 200
        contentAsString(resultB) must_== content
        status(useOldEtag()) must_== 200
        status(useOldEtag(from = resultB)) must_== NOT_MODIFIED
      }
    }
  }
}

this causes sending a complete response to all clients every time cache entry expires, even if it did not change

Activity

  1. added 2 commits that reference this issue on Feb 12, 2026
    bfcd645
    807df79
  2. added a commit that references this issue on Apr 29, 2026
    440dfd9
  3. added a commit that references this issue on Jun 19, 2026
    a1fd253
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions