#1000·Nginx

proxy_cache_use_stale doesn't refresh cache timer when error responses continue

Author: jwheareCreated Nov 21, 2025Updated Sep 15, 2026
Labelsbugneeds-refinement

Environment

Running in a docker container, latest official build

# nginx -V
nginx version: nginx/1.29.3
built by gcc 14.2.0 (Debian 14.2.0-19)
built with OpenSSL 3.5.1 1 Jul 2025 (running with OpenSSL 3.5.4 30 Sep 2025)
TLS SNI support enabled
configure arguments: --prefix=/etc/nginx --sbin-path=/usr/sbin/nginx --modules-path=/usr/lib/nginx/modules --conf-path=/etc/nginx/nginx.conf --error-log-path=/var/log/nginx/error.log --http-log-path=/var/log/nginx/access.log --pid-path=/run/nginx.pid --lock-path=/run/nginx.lock --http-client-body-temp-path=/var/cache/nginx/client_temp --http-proxy-temp-path=/var/cache/nginx/proxy_temp --http-fastcgi-temp-path=/var/cache/nginx/fastcgi_temp --http-uwsgi-temp-path=/var/cache/nginx/uwsgi_temp --http-scgi-temp-path=/var/cache/nginx/scgi_temp --user=nginx --group=nginx --with-compat --with-file-aio --with-threads --with-http_addition_module --with-http_auth_request_module --with-http_dav_module --with-http_flv_module --with-http_gunzip_module --with-http_gzip_static_module --with-http_mp4_module --with-http_random_index_module --with-http_realip_module --with-http_secure_link_module --with-http_slice_module --with-http_ssl_module --with-http_stub_status_module --with-http_sub_module --with-http_v2_module --with-http_v3_module --with-mail --with-mail_ssl_module --with-stream --with-stream_realip_module --with-stream_ssl_module --with-stream_ssl_preread_module --with-cc-opt='-g -O2 -Werror=implicit-function-declaration -ffile-prefix-map=/home/builder/debuild/nginx-1.29.3/debian/debuild-base/nginx-1.29.3=. -fstack-protector-strong -fstack-clash-protection -Wformat -Werror=format-security -fcf-protection -Wp,-D_FORTIFY_SOURCE=2 -fPIC' --with-ld-opt='-Wl,-z,relro -Wl,-z,now -Wl,--as-needed -pie'

# uname -a
Linux 0143a472b060 6.10.14-linuxkit #1 SMP PREEMPT_DYNAMIC Sat May 17 08:29:19 UTC 2025 x86_64 GNU/Linux

Description

I've set up my origin to return Cache-Control: max-age=60 with 429 Too Many Requests errors. These are triggered by a backend fetch to a 3rd party API. I want the error to be cached for a short period by nginx so that downstream requests don't keep repeatedly hitting the origin to reduce contribution to the rate limiting.

I'm trying to achieve this with the following directive:

proxy_cache_use_stale http_429;

The behaviour I'm seeing is:

  • T=00 Request 1 -> 429 -> origin returns an error which is cached
  • T=10 Request 2 -> HIT -> origin is not reached
  • T=70 Request 3 -> STALE -> origin returns an error which is not cached
  • T=80 Request 4 -> STALE -> origin returns an error which is not cached

Expected behaviour:

  • T=70 Request 3 -> STALE -> origin returns an error which is cached
  • T=80 Request 4 -> HIT -> origin is not reached

If I disable proxy_cache_use_stale I see this:

  • T=00 Request 1 -> 429 -> origin returns an error which is cached
  • T=10 Request 2 -> HIT -> origin is not reached
  • T=70 Request 3 -> EXPIRED -> origin returns an error which is cached
  • T=80 Request 4 -> HIT -> origin is not reached

Which is fine, but in the case where the cache has good data in it, I really would like the ability to serve it as stale in place of the 429.

I've tried various combinations of proxy_cache_use_stale proxy_cache_background_update proxy_cache_revalidate proxy_cache_valid to no avail.

I don't want to use proxy_ignore_headers because I want the origin to control the cache age for different endpoints.

  • The bug is reproducible with the latest version of nginx
  • The nginx configuration is minimized to the smallest possible to reproduce the issue and doesn't contain third-party modules

nginx configuration

upstream app_server {
    # fail_timeout=0 means we always retry an upstream even if it failed
    # to return a good HTTP response
    server origin:8000 fail_timeout=0;
}

proxy_cache_path
    /usr/share/nginx/cache
    levels=1:2
    keys_zone=disk_cache:10m
    max_size=100m
    inactive=60m
    use_temp_path=off;

server {
    listen ${NGINX_PORT};
    server_name ${NGINX_HOST};
    
    root  /usr/share/nginx/html;
    index index.html;

    charset UTF-8;

    location / {
        try_files $uri @app;
    }

    location @app {
      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
      proxy_set_header X-Forwarded-Proto $scheme;
      proxy_set_header Host $http_host;
      # we don't want nginx trying to do something clever with
      # redirects, we set the Host: header above already.
      proxy_redirect off;
      proxy_pass http://app_server;

      proxy_cache disk_cache;
      proxy_cache_lock on;
      proxy_cache_lock_age 5s;
      proxy_cache_lock_timeout 10s;
      proxy_cache_use_stale http_429;

      add_header "X-Cache-Status" $upstream_cache_status always;
      add_header "X-Real-Status" $status always;
    }
}