From 698cc3f4abf50bc27a7cfb21b0cf0b3d772d7edd Mon Sep 17 00:00:00 2001 From: Vojtech Vitek Date: Tue, 29 Sep 2026 16:41:47 +0200 Subject: [PATCH 01/11] middleware: add Deprecation (RFC 9745), make Sunset RFC 8594 only Sunset used to set Deprecation to an HTTP-date, following the old draft. RFC 9745 defines Deprecation as a Structured Field Date (e.g. @1766571600), so that value was unparseable for compliant clients. It also conflated the deprecation date with the sunset date. - Add Deprecation(deprecatedAt, links...) per RFC 9745. - Sunset now sets only the Sunset header per RFC 8594. - Sunset formats the date in UTC so non-UTC times emit a valid GMT date. Breaking for anyone relying on Sunset to also set Deprecation: add Deprecation to the chain explicitly. --- README.md | 4 +- middleware/deprecation.go | 30 ++++++++++++++ middleware/deprecation_test.go | 76 ++++++++++++++++++++++++++++++++++ middleware/sunset.go | 13 +++--- middleware/sunset_test.go | 18 ++++---- 5 files changed, 126 insertions(+), 15 deletions(-) create mode 100644 middleware/deprecation.go create mode 100644 middleware/deprecation_test.go diff --git a/README.md b/README.md index 5f9f44356..eab1d2101 100644 --- a/README.md +++ b/README.md @@ -345,6 +345,7 @@ with `net/http` can be used with chi's mux. | [Compress] | Gzip compression for clients that accept compressed responses | | [ContentCharset] | Ensure charset for Content-Type request headers | | [CleanPath] | Clean double slashes from request path | +| [Deprecation] | Set the Deprecation response header (RFC 9745) | | [GetHead] | Automatically route undefined HEAD requests to GET handlers | | [Heartbeat] | Monitoring endpoint to check the servers pulse | | [Logger] | Logs the start and end of each request with the elapsed processing time | @@ -361,7 +362,7 @@ with `net/http` can be used with chi's mux. | [RouteHeaders] | Route handling for request headers | | [SetHeader] | Short-hand middleware to set a response header key/value | | [StripSlashes] | Strip slashes on routing paths | -| [Sunset] | Sunset set Deprecation/Sunset header to response | +| [Sunset] | Set the Sunset response header (RFC 8594) | | [Throttle] | Puts a ceiling on the number of concurrent requests | | [Timeout] | Signals to the request context when the timeout deadline is reached | | [URLFormat] | Parse extension from url and put it on request context | @@ -374,6 +375,7 @@ with `net/http` can be used with chi's mux. [Compress]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#Compress [ContentCharset]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#ContentCharset [CleanPath]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#CleanPath +[Deprecation]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#Deprecation [GetHead]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#GetHead [GetReqID]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#GetReqID [Heartbeat]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#Heartbeat diff --git a/middleware/deprecation.go b/middleware/deprecation.go new file mode 100644 index 000000000..1cb3c6cbd --- /dev/null +++ b/middleware/deprecation.go @@ -0,0 +1,30 @@ +package middleware + +import ( + "net/http" + "strconv" + "time" +) + +// Deprecation sets the Deprecation header on the response, per RFC 9745. +// https://www.rfc-editor.org/rfc/rfc9745.html +// +// It can be used on a route or a route group. Each link is added as a Link +// header, e.g. `; rel="deprecation"`. +// +// To also announce a removal date, combine it with Sunset. +func Deprecation(deprecatedAt time.Time, links ...string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !deprecatedAt.IsZero() { + // RFC 9745 uses a Structured Field Date (RFC 8941), not an HTTP-date. + w.Header().Set("Deprecation", "@"+strconv.FormatInt(deprecatedAt.Unix(), 10)) + + for _, link := range links { + w.Header().Add("Link", link) + } + } + next.ServeHTTP(w, r) + }) + } +} diff --git a/middleware/deprecation_test.go b/middleware/deprecation_test.go new file mode 100644 index 000000000..698bdba1f --- /dev/null +++ b/middleware/deprecation_test.go @@ -0,0 +1,76 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/go-chi/chi/v5" +) + +func TestDeprecation(t *testing.T) { + deprecatedAt := time.Date(2025, 12, 24, 10, 20, 0, 0, time.UTC) + + serve := func(mw func(http.Handler) http.Handler) http.Header { + r := chi.NewRouter() + r.Use(mw) + r.Get("/", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("ok")) + }) + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/", nil) + r.ServeHTTP(w, req) + if w.Code != 200 { + t.Fatal("Response Code should be 200") + } + return w.Header() + } + + t.Run("Deprecation without link", func(t *testing.T) { + h := serve(Deprecation(deprecatedAt)) + + if got, want := h.Get("Deprecation"), "@1766571600"; got != want { + t.Fatalf("Deprecation = %q, want %q", got, want) + } + if h.Get("Sunset") != "" { + t.Fatal("Deprecation should not set Sunset.") + } + if h.Get("Link") != "" { + t.Fatal("Link should be empty.") + } + }) + + t.Run("Deprecation with link", func(t *testing.T) { + link := `; rel="deprecation"` + h := serve(Deprecation(deprecatedAt, link)) + + if got, want := h.Get("Deprecation"), "@1766571600"; got != want { + t.Fatalf("Deprecation = %q, want %q", got, want) + } + if got := h.Get("Link"); got != link { + t.Fatalf("Link = %q, want %q", got, link) + } + }) + + t.Run("Zero time sets nothing", func(t *testing.T) { + h := serve(Deprecation(time.Time{})) + if h.Get("Deprecation") != "" { + t.Fatal("Deprecation should be empty for zero time.") + } + }) + + t.Run("Combined with Sunset", func(t *testing.T) { + sunsetAt := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + h := serve(func(next http.Handler) http.Handler { + return Deprecation(deprecatedAt)(Sunset(sunsetAt)(next)) + }) + + if h.Get("Deprecation") != "@1766571600" { + t.Fatal("Deprecation header missing.") + } + if got, want := h.Get("Sunset"), "Mon, 01 Jun 2026 00:00:00 GMT"; got != want { + t.Fatalf("Sunset = %q, want %q", got, want) + } + }) +} diff --git a/middleware/sunset.go b/middleware/sunset.go index 18815d585..4dfad81c1 100644 --- a/middleware/sunset.go +++ b/middleware/sunset.go @@ -5,15 +5,18 @@ import ( "time" ) -// Sunset set Deprecation/Sunset header to response -// This can be used to enable Sunset in a route or a route group -// For more: https://www.rfc-editor.org/rfc/rfc8594.html +// Sunset sets the Sunset header on the response, per RFC 8594. +// https://www.rfc-editor.org/rfc/rfc8594.html +// +// It can be used on a route or a route group. Each link is added as a Link +// header, e.g. `; rel="sunset"`. +// +// To also signal deprecation, combine it with Deprecation. func Sunset(sunsetAt time.Time, links ...string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !sunsetAt.IsZero() { - w.Header().Set("Sunset", sunsetAt.Format(http.TimeFormat)) - w.Header().Set("Deprecation", sunsetAt.Format(http.TimeFormat)) + w.Header().Set("Sunset", sunsetAt.UTC().Format(http.TimeFormat)) for _, link := range links { w.Header().Add("Link", link) diff --git a/middleware/sunset_test.go b/middleware/sunset_test.go index a36c793da..b44b687b6 100644 --- a/middleware/sunset_test.go +++ b/middleware/sunset_test.go @@ -37,8 +37,8 @@ func TestSunset(t *testing.T) { t.Fatal("Test get sunset error.", sunset) } - if deprecation != "Wed, 24 Dec 2025 10:20:00 GMT" { - t.Fatal("Test get deprecation error.") + if deprecation != "" { + t.Fatal("Sunset should not set Deprecation.", deprecation) } }) @@ -49,8 +49,8 @@ func TestSunset(t *testing.T) { r := chi.NewRouter() sunsetAt := time.Date(2025, 12, 24, 10, 20, 0, 0, time.UTC) - deprecationLink := "https://example.com/v1/deprecation-details" - r.Use(Sunset(sunsetAt, deprecationLink)) + sunsetLink := "https://example.com/v1/sunset-details" + r.Use(Sunset(sunsetAt, sunsetLink)) var sunset, deprecation, link string r.Get("/", func(w http.ResponseWriter, r *http.Request) { @@ -72,12 +72,12 @@ func TestSunset(t *testing.T) { t.Fatal("Test get sunset error.", sunset) } - if deprecation != "Wed, 24 Dec 2025 10:20:00 GMT" { - t.Fatal("Test get deprecation error.") + if deprecation != "" { + t.Fatal("Sunset should not set Deprecation.", deprecation) } - if link != deprecationLink { - t.Fatal("Test get deprecation link error.") + if link != sunsetLink { + t.Fatal("Test get sunset link error.") } }) @@ -92,7 +92,7 @@ func main() { r.Use(middleware.Sunset(sunsetAt)) // can provide additional link for updated resource - // r.Use(middleware.Sunset(sunsetAt, "https://example.com/v1/deprecation-details")) + // r.Use(middleware.Sunset(sunsetAt, "https://example.com/v1/sunset-details")) r.Get("/", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("This endpoint will be removed soon")) From 2097683013dcce389abb50c461311b3cd7084232 Mon Sep 17 00:00:00 2001 From: Vojtech Vitek Date: Tue, 29 Sep 2026 18:05:12 +0200 Subject: [PATCH 02/11] middleware: cross-link Sunset and Deprecation in docs --- README.md | 4 ++-- middleware/deprecation.go | 2 +- middleware/sunset.go | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index eab1d2101..25082325b 100644 --- a/README.md +++ b/README.md @@ -345,7 +345,7 @@ with `net/http` can be used with chi's mux. | [Compress] | Gzip compression for clients that accept compressed responses | | [ContentCharset] | Ensure charset for Content-Type request headers | | [CleanPath] | Clean double slashes from request path | -| [Deprecation] | Set the Deprecation response header (RFC 9745) | +| [Deprecation] | Set the Deprecation response header (RFC 9745); then [Sunset] | | [GetHead] | Automatically route undefined HEAD requests to GET handlers | | [Heartbeat] | Monitoring endpoint to check the servers pulse | | [Logger] | Logs the start and end of each request with the elapsed processing time | @@ -362,7 +362,7 @@ with `net/http` can be used with chi's mux. | [RouteHeaders] | Route handling for request headers | | [SetHeader] | Short-hand middleware to set a response header key/value | | [StripSlashes] | Strip slashes on routing paths | -| [Sunset] | Set the Sunset response header (RFC 8594) | +| [Sunset] | Set the Sunset response header (RFC 8594); after [Deprecation] | | [Throttle] | Puts a ceiling on the number of concurrent requests | | [Timeout] | Signals to the request context when the timeout deadline is reached | | [URLFormat] | Parse extension from url and put it on request context | diff --git a/middleware/deprecation.go b/middleware/deprecation.go index 1cb3c6cbd..49031d6d4 100644 --- a/middleware/deprecation.go +++ b/middleware/deprecation.go @@ -12,7 +12,7 @@ import ( // It can be used on a route or a route group. Each link is added as a Link // header, e.g. `; rel="deprecation"`. // -// To also announce a removal date, combine it with Sunset. +// Once a removal date is known, add [Sunset] to announce it. func Deprecation(deprecatedAt time.Time, links ...string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/middleware/sunset.go b/middleware/sunset.go index 4dfad81c1..ec2bd203a 100644 --- a/middleware/sunset.go +++ b/middleware/sunset.go @@ -11,7 +11,7 @@ import ( // It can be used on a route or a route group. Each link is added as a Link // header, e.g. `; rel="sunset"`. // -// To also signal deprecation, combine it with Deprecation. +// Typically used after [Deprecation]: deprecate first, then announce the sunset. func Sunset(sunsetAt time.Time, links ...string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { From 6ee9bf381628ace8969f1b002cd544f626f2b3e2 Mon Sep 17 00:00:00 2001 From: Vojtech Vitek Date: Wed, 30 Sep 2026 01:26:27 +0200 Subject: [PATCH 03/11] middleware: address review on Sunset/Deprecation docs and Link examples --- README.md | 4 ++-- middleware/deprecation.go | 7 ++++--- middleware/sunset.go | 7 ++++--- middleware/sunset_test.go | 6 +++--- 4 files changed, 13 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 25082325b..be6cb7bb9 100644 --- a/README.md +++ b/README.md @@ -345,7 +345,7 @@ with `net/http` can be used with chi's mux. | [Compress] | Gzip compression for clients that accept compressed responses | | [ContentCharset] | Ensure charset for Content-Type request headers | | [CleanPath] | Clean double slashes from request path | -| [Deprecation] | Set the Deprecation response header (RFC 9745); then [Sunset] | +| [Deprecation] | Set the Deprecation response header (RFC 9745); see also [Sunset] | | [GetHead] | Automatically route undefined HEAD requests to GET handlers | | [Heartbeat] | Monitoring endpoint to check the servers pulse | | [Logger] | Logs the start and end of each request with the elapsed processing time | @@ -362,7 +362,7 @@ with `net/http` can be used with chi's mux. | [RouteHeaders] | Route handling for request headers | | [SetHeader] | Short-hand middleware to set a response header key/value | | [StripSlashes] | Strip slashes on routing paths | -| [Sunset] | Set the Sunset response header (RFC 8594); after [Deprecation] | +| [Sunset] | Set the Sunset response header (RFC 8594); see also [Deprecation] | | [Throttle] | Puts a ceiling on the number of concurrent requests | | [Timeout] | Signals to the request context when the timeout deadline is reached | | [URLFormat] | Parse extension from url and put it on request context | diff --git a/middleware/deprecation.go b/middleware/deprecation.go index 49031d6d4..6e164b167 100644 --- a/middleware/deprecation.go +++ b/middleware/deprecation.go @@ -9,10 +9,11 @@ import ( // Deprecation sets the Deprecation header on the response, per RFC 9745. // https://www.rfc-editor.org/rfc/rfc9745.html // -// It can be used on a route or a route group. Each link is added as a Link -// header, e.g. `; rel="deprecation"`. +// It can be used on a route or a route group. Each link is added as-is as a +// Link header, so it must be a full RFC 8288 value, e.g. +// `; rel="deprecation"`. // -// Once a removal date is known, add [Sunset] to announce it. +// Often paired with [Sunset]. Middleware order doesn't matter. func Deprecation(deprecatedAt time.Time, links ...string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/middleware/sunset.go b/middleware/sunset.go index ec2bd203a..6be1bd7a5 100644 --- a/middleware/sunset.go +++ b/middleware/sunset.go @@ -8,10 +8,11 @@ import ( // Sunset sets the Sunset header on the response, per RFC 8594. // https://www.rfc-editor.org/rfc/rfc8594.html // -// It can be used on a route or a route group. Each link is added as a Link -// header, e.g. `; rel="sunset"`. +// It can be used on a route or a route group. Each link is added as-is as a +// Link header, so it must be a full RFC 8288 value, e.g. +// `; rel="sunset"`. // -// Typically used after [Deprecation]: deprecate first, then announce the sunset. +// Often paired with [Deprecation]. Middleware order doesn't matter. func Sunset(sunsetAt time.Time, links ...string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/middleware/sunset_test.go b/middleware/sunset_test.go index b44b687b6..cb840c45f 100644 --- a/middleware/sunset_test.go +++ b/middleware/sunset_test.go @@ -49,7 +49,7 @@ func TestSunset(t *testing.T) { r := chi.NewRouter() sunsetAt := time.Date(2025, 12, 24, 10, 20, 0, 0, time.UTC) - sunsetLink := "https://example.com/v1/sunset-details" + sunsetLink := `; rel="sunset"` r.Use(Sunset(sunsetAt, sunsetLink)) var sunset, deprecation, link string @@ -91,8 +91,8 @@ func main() { sunsetAt := time.Date(2025, 12, 24, 10, 20, 0, 0, time.UTC) r.Use(middleware.Sunset(sunsetAt)) - // can provide additional link for updated resource - // r.Use(middleware.Sunset(sunsetAt, "https://example.com/v1/sunset-details")) + // can provide a Link header value (RFC 8288) pointing at more details + // r.Use(middleware.Sunset(sunsetAt, `; rel="sunset"`)) r.Get("/", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("This endpoint will be removed soon")) From 8715ef74413810923ec8ced77b6879b6c50e585a Mon Sep 17 00:00:00 2001 From: Vojtech Vitek Date: Wed, 30 Sep 2026 01:27:35 +0200 Subject: [PATCH 04/11] middleware: recommend deprecate-then-sunset lifecycle in docs --- middleware/deprecation.go | 3 ++- middleware/sunset.go | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/middleware/deprecation.go b/middleware/deprecation.go index 6e164b167..7081e7c56 100644 --- a/middleware/deprecation.go +++ b/middleware/deprecation.go @@ -13,7 +13,8 @@ import ( // Link header, so it must be a full RFC 8288 value, e.g. // `; rel="deprecation"`. // -// Often paired with [Sunset]. Middleware order doesn't matter. +// Recommended lifecycle: deprecate first, then announce a removal date with +// [Sunset]. Middleware order doesn't matter. func Deprecation(deprecatedAt time.Time, links ...string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/middleware/sunset.go b/middleware/sunset.go index 6be1bd7a5..b96b9521b 100644 --- a/middleware/sunset.go +++ b/middleware/sunset.go @@ -12,7 +12,8 @@ import ( // Link header, so it must be a full RFC 8288 value, e.g. // `; rel="sunset"`. // -// Often paired with [Deprecation]. Middleware order doesn't matter. +// Recommended lifecycle: deprecate first with [Deprecation], then announce the +// sunset. Middleware order doesn't matter. func Sunset(sunsetAt time.Time, links ...string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { From 60a0c22d7090057a51f1ebda25c82b94ed661073 Mon Sep 17 00:00:00 2001 From: Vojtech Vitek Date: Wed, 30 Sep 2026 15:23:36 +0200 Subject: [PATCH 05/11] middleware: cite RFC 9651 for the Structured Field Date type --- middleware/deprecation.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/middleware/deprecation.go b/middleware/deprecation.go index 7081e7c56..51e800af4 100644 --- a/middleware/deprecation.go +++ b/middleware/deprecation.go @@ -19,7 +19,7 @@ func Deprecation(deprecatedAt time.Time, links ...string) func(http.Handler) htt return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !deprecatedAt.IsZero() { - // RFC 9745 uses a Structured Field Date (RFC 8941), not an HTTP-date. + // RFC 9745 uses a Structured Field Date (RFC 9651), not an HTTP-date. w.Header().Set("Deprecation", "@"+strconv.FormatInt(deprecatedAt.Unix(), 10)) for _, link := range links { From 0d8c2157d54518d9fe8d44f7e76448598d585458 Mon Sep 17 00:00:00 2001 From: Vojtech Vitek Date: Wed, 30 Sep 2026 15:26:20 +0200 Subject: [PATCH 06/11] middleware: panic on zero time in Sunset and Deprecation A zero time is almost always an unset value (ignored parse error, missing config field). Failing at router construction surfaces it at startup instead of silently dropping the header. --- middleware/deprecation.go | 18 ++++++++++++------ middleware/deprecation_test.go | 12 +++++++----- middleware/sunset.go | 14 +++++++++----- middleware/sunset_test.go | 8 ++++++++ 4 files changed, 36 insertions(+), 16 deletions(-) diff --git a/middleware/deprecation.go b/middleware/deprecation.go index 51e800af4..382eba31c 100644 --- a/middleware/deprecation.go +++ b/middleware/deprecation.go @@ -13,18 +13,24 @@ import ( // Link header, so it must be a full RFC 8288 value, e.g. // `; rel="deprecation"`. // +// It panics if deprecatedAt is the zero time, which is usually an unset value. +// // Recommended lifecycle: deprecate first, then announce a removal date with // [Sunset]. Middleware order doesn't matter. func Deprecation(deprecatedAt time.Time, links ...string) func(http.Handler) http.Handler { + if deprecatedAt.IsZero() { + panic("middleware.Deprecation: deprecatedAt must not be the zero time") + } + + // RFC 9745 uses a Structured Field Date (RFC 9651), not an HTTP-date. + value := "@" + strconv.FormatInt(deprecatedAt.Unix(), 10) + return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if !deprecatedAt.IsZero() { - // RFC 9745 uses a Structured Field Date (RFC 9651), not an HTTP-date. - w.Header().Set("Deprecation", "@"+strconv.FormatInt(deprecatedAt.Unix(), 10)) + w.Header().Set("Deprecation", value) - for _, link := range links { - w.Header().Add("Link", link) - } + for _, link := range links { + w.Header().Add("Link", link) } next.ServeHTTP(w, r) }) diff --git a/middleware/deprecation_test.go b/middleware/deprecation_test.go index 698bdba1f..1bfea09af 100644 --- a/middleware/deprecation_test.go +++ b/middleware/deprecation_test.go @@ -53,11 +53,13 @@ func TestDeprecation(t *testing.T) { } }) - t.Run("Zero time sets nothing", func(t *testing.T) { - h := serve(Deprecation(time.Time{})) - if h.Get("Deprecation") != "" { - t.Fatal("Deprecation should be empty for zero time.") - } + t.Run("Zero time panics", func(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("Deprecation should panic for zero time.") + } + }() + Deprecation(time.Time{}) }) t.Run("Combined with Sunset", func(t *testing.T) { diff --git a/middleware/sunset.go b/middleware/sunset.go index b96b9521b..aca75704a 100644 --- a/middleware/sunset.go +++ b/middleware/sunset.go @@ -12,17 +12,21 @@ import ( // Link header, so it must be a full RFC 8288 value, e.g. // `; rel="sunset"`. // +// It panics if sunsetAt is the zero time, which is usually an unset value. +// // Recommended lifecycle: deprecate first with [Deprecation], then announce the // sunset. Middleware order doesn't matter. func Sunset(sunsetAt time.Time, links ...string) func(http.Handler) http.Handler { + if sunsetAt.IsZero() { + panic("middleware.Sunset: sunsetAt must not be the zero time") + } + return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if !sunsetAt.IsZero() { - w.Header().Set("Sunset", sunsetAt.UTC().Format(http.TimeFormat)) + w.Header().Set("Sunset", sunsetAt.UTC().Format(http.TimeFormat)) - for _, link := range links { - w.Header().Add("Link", link) - } + for _, link := range links { + w.Header().Add("Link", link) } next.ServeHTTP(w, r) }) diff --git a/middleware/sunset_test.go b/middleware/sunset_test.go index cb840c45f..fc86b4d92 100644 --- a/middleware/sunset_test.go +++ b/middleware/sunset_test.go @@ -81,6 +81,14 @@ func TestSunset(t *testing.T) { } }) + t.Run("Zero time panics", func(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("Sunset should panic for zero time.") + } + }() + Sunset(time.Time{}) + }) } /** From 5b12ee167dde71b6660c1c75253d9d755305c8b0 Mon Sep 17 00:00:00 2001 From: Vojtech Vitek Date: Wed, 30 Sep 2026 15:27:48 +0200 Subject: [PATCH 07/11] middleware: document how to fix the zero-time panic --- middleware/deprecation.go | 3 ++- middleware/sunset.go | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/middleware/deprecation.go b/middleware/deprecation.go index 382eba31c..74363c3cb 100644 --- a/middleware/deprecation.go +++ b/middleware/deprecation.go @@ -13,7 +13,8 @@ import ( // Link header, so it must be a full RFC 8288 value, e.g. // `; rel="deprecation"`. // -// It panics if deprecatedAt is the zero time, which is usually an unset value. +// It panics if %s is the zero time, which is usually an unset value. +// Pass a real date, or skip the middleware when no date is set. // // Recommended lifecycle: deprecate first, then announce a removal date with // [Sunset]. Middleware order doesn't matter. diff --git a/middleware/sunset.go b/middleware/sunset.go index aca75704a..9ee28f8fa 100644 --- a/middleware/sunset.go +++ b/middleware/sunset.go @@ -12,7 +12,8 @@ import ( // Link header, so it must be a full RFC 8288 value, e.g. // `; rel="sunset"`. // -// It panics if sunsetAt is the zero time, which is usually an unset value. +// It panics if %s is the zero time, which is usually an unset value. +// Pass a real date, or skip the middleware when no date is set. // // Recommended lifecycle: deprecate first with [Deprecation], then announce the // sunset. Middleware order doesn't matter. From 98c007a1b384dc3b4ba725b11db59466f52fe8fb Mon Sep 17 00:00:00 2001 From: Vojtech Vitek Date: Wed, 30 Sep 2026 15:30:07 +0200 Subject: [PATCH 08/11] middleware: fix placeholder in zero-time panic godoc --- middleware/deprecation.go | 2 +- middleware/sunset.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/middleware/deprecation.go b/middleware/deprecation.go index 74363c3cb..086e2d3e8 100644 --- a/middleware/deprecation.go +++ b/middleware/deprecation.go @@ -13,7 +13,7 @@ import ( // Link header, so it must be a full RFC 8288 value, e.g. // `; rel="deprecation"`. // -// It panics if %s is the zero time, which is usually an unset value. +// It panics if deprecatedAt is the zero time, which is usually an unset value. // Pass a real date, or skip the middleware when no date is set. // // Recommended lifecycle: deprecate first, then announce a removal date with diff --git a/middleware/sunset.go b/middleware/sunset.go index 9ee28f8fa..12dfe95cd 100644 --- a/middleware/sunset.go +++ b/middleware/sunset.go @@ -12,7 +12,7 @@ import ( // Link header, so it must be a full RFC 8288 value, e.g. // `; rel="sunset"`. // -// It panics if %s is the zero time, which is usually an unset value. +// It panics if sunsetAt is the zero time, which is usually an unset value. // Pass a real date, or skip the middleware when no date is set. // // Recommended lifecycle: deprecate first with [Deprecation], then announce the From 71aa2d23e75d54f73239a385a1b6de662c10297d Mon Sep 17 00:00:00 2001 From: Vojtech Vitek Date: Wed, 30 Sep 2026 15:30:41 +0200 Subject: [PATCH 09/11] middleware: shorten zero-time panic messages --- middleware/deprecation.go | 2 +- middleware/sunset.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/middleware/deprecation.go b/middleware/deprecation.go index 086e2d3e8..0a6e6accd 100644 --- a/middleware/deprecation.go +++ b/middleware/deprecation.go @@ -20,7 +20,7 @@ import ( // [Sunset]. Middleware order doesn't matter. func Deprecation(deprecatedAt time.Time, links ...string) func(http.Handler) http.Handler { if deprecatedAt.IsZero() { - panic("middleware.Deprecation: deprecatedAt must not be the zero time") + panic("middleware.Deprecation: deprecatedAt must not be zero") } // RFC 9745 uses a Structured Field Date (RFC 9651), not an HTTP-date. diff --git a/middleware/sunset.go b/middleware/sunset.go index 12dfe95cd..55a32136d 100644 --- a/middleware/sunset.go +++ b/middleware/sunset.go @@ -19,7 +19,7 @@ import ( // sunset. Middleware order doesn't matter. func Sunset(sunsetAt time.Time, links ...string) func(http.Handler) http.Handler { if sunsetAt.IsZero() { - panic("middleware.Sunset: sunsetAt must not be the zero time") + panic("middleware.Sunset: sunsetAt must not be zero") } return func(next http.Handler) http.Handler { From 31374404ce31b7d0f60f29a0a9d99079a6a26087 Mon Sep 17 00:00:00 2001 From: Vojtech Vitek Date: Wed, 30 Sep 2026 15:34:50 +0200 Subject: [PATCH 10/11] middleware: guide users on choosing Deprecation and Sunset --- README.md | 24 ++++++++++++++++++++++++ middleware/deprecation.go | 13 +++++++------ middleware/sunset.go | 14 ++++++++------ 3 files changed, 39 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index be6cb7bb9..0d8886f44 100644 --- a/README.md +++ b/README.md @@ -476,6 +476,30 @@ See the per-function godoc for the full semantics of each middleware, and [adam-p's "The perils of the 'real' client IP"](https://adam-p.ca/blog/2022/03/x-forwarded-for/) for the underlying threat model. +### Deprecating and removing a route + +Use `Deprecation` first. Add `Sunset` when you know the removal date. + +| Situation | Use | +| :------------------------------------------------------- | :-------------------------- | +| Route works, clients should migrate, no removal date yet | `Deprecation` | +| You know the date the route will stop working | `Deprecation` + `Sunset` | +| Route is already removed | Neither. Return `410 Gone`. | + +```go +// A bad date parses to the zero time, and the middleware panics at startup. +deprecatedAt, _ := time.Parse(time.DateOnly, "2026-01-15") +sunsetAt, _ := time.Parse(time.DateOnly, "2026-07-01") + +// Step 1: the route is deprecated. +r.With(middleware.Deprecation(deprecatedAt)).Get("/v1/users", listUsers) + +// Step 2: the removal date is known. +r.With(middleware.Deprecation(deprecatedAt), middleware.Sunset(sunsetAt)).Get("/v1/users", listUsers) + +// After sunsetAt: remove the route, or return 410 Gone. +``` + ### Extra middlewares & packages Please see https://github.com/go-chi for additional packages. diff --git a/middleware/deprecation.go b/middleware/deprecation.go index 0a6e6accd..649e1ddbd 100644 --- a/middleware/deprecation.go +++ b/middleware/deprecation.go @@ -6,18 +6,19 @@ import ( "time" ) -// Deprecation sets the Deprecation header on the response, per RFC 9745. +// Deprecation marks a route as deprecated, per RFC 9745. // https://www.rfc-editor.org/rfc/rfc9745.html // -// It can be used on a route or a route group. Each link is added as-is as a -// Link header, so it must be a full RFC 8288 value, e.g. -// `; rel="deprecation"`. +// Use it from the day you decide to retire a route. The route keeps working, +// and clients are told to migrate. When you know the removal date, add [Sunset]. +// Middleware order doesn't matter. // +// deprecatedAt is when the route was, or will be, deprecated. A past date is fine. // It panics if deprecatedAt is the zero time, which is usually an unset value. // Pass a real date, or skip the middleware when no date is set. // -// Recommended lifecycle: deprecate first, then announce a removal date with -// [Sunset]. Middleware order doesn't matter. +// Each link is added as-is as a Link header, so it must be a full RFC 8288 +// value, e.g. `; rel="deprecation"`. func Deprecation(deprecatedAt time.Time, links ...string) func(http.Handler) http.Handler { if deprecatedAt.IsZero() { panic("middleware.Deprecation: deprecatedAt must not be zero") diff --git a/middleware/sunset.go b/middleware/sunset.go index 55a32136d..f59b5d242 100644 --- a/middleware/sunset.go +++ b/middleware/sunset.go @@ -5,18 +5,20 @@ import ( "time" ) -// Sunset sets the Sunset header on the response, per RFC 8594. +// Sunset announces when a route will stop working, per RFC 8594. // https://www.rfc-editor.org/rfc/rfc8594.html // -// It can be used on a route or a route group. Each link is added as-is as a -// Link header, so it must be a full RFC 8288 value, e.g. -// `; rel="sunset"`. +// Use it once you know the removal date, and keep [Deprecation] on the same +// route. Sunset alone is valid, but clients get no "stop using this" signal. +// Middleware order doesn't matter. // +// sunsetAt is a future date, and should not be before the deprecation date. +// After it, remove the route or return 410 Gone. // It panics if sunsetAt is the zero time, which is usually an unset value. // Pass a real date, or skip the middleware when no date is set. // -// Recommended lifecycle: deprecate first with [Deprecation], then announce the -// sunset. Middleware order doesn't matter. +// Each link is added as-is as a Link header, so it must be a full RFC 8288 +// value, e.g. `; rel="sunset"`. func Sunset(sunsetAt time.Time, links ...string) func(http.Handler) http.Handler { if sunsetAt.IsZero() { panic("middleware.Sunset: sunsetAt must not be zero") From be8ab91ba404392f0c5ac210cb238e3ff61147ae Mon Sep 17 00:00:00 2001 From: Vojtech Vitek Date: Wed, 30 Sep 2026 16:18:47 +0200 Subject: [PATCH 11/11] middleware: test multiple Link values for Sunset and Deprecation --- middleware/deprecation_test.go | 27 +++++++++++++++++++++++++++ middleware/sunset.go | 2 +- middleware/sunset_test.go | 18 ++++++++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/middleware/deprecation_test.go b/middleware/deprecation_test.go index 1bfea09af..7dbe8339a 100644 --- a/middleware/deprecation_test.go +++ b/middleware/deprecation_test.go @@ -53,6 +53,17 @@ func TestDeprecation(t *testing.T) { } }) + t.Run("Deprecation with multiple links", func(t *testing.T) { + docs := `; rel="deprecation"` + next := `; rel="successor-version"` + h := serve(Deprecation(deprecatedAt, docs, next)) + + got := h.Values("Link") + if len(got) != 2 || got[0] != docs || got[1] != next { + t.Fatalf("Link = %q, want [%q %q]", got, docs, next) + } + }) + t.Run("Zero time panics", func(t *testing.T) { defer func() { if recover() == nil { @@ -71,8 +82,24 @@ func TestDeprecation(t *testing.T) { if h.Get("Deprecation") != "@1766571600" { t.Fatal("Deprecation header missing.") } + if h.Get("Link") != "" { + t.Fatal("Link should be empty.") + } if got, want := h.Get("Sunset"), "Mon, 01 Jun 2026 00:00:00 GMT"; got != want { t.Fatalf("Sunset = %q, want %q", got, want) } }) + t.Run("Combined with Sunset keeps links from both", func(t *testing.T) { + sunsetAt := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + dep := `; rel="deprecation"` + sun := `; rel="sunset"` + h := serve(func(next http.Handler) http.Handler { + return Deprecation(deprecatedAt, dep)(Sunset(sunsetAt, sun)(next)) + }) + + got := h.Values("Link") + if len(got) != 2 || got[0] != dep || got[1] != sun { + t.Fatalf("Link = %q, want [%q %q]", got, dep, sun) + } + }) } diff --git a/middleware/sunset.go b/middleware/sunset.go index f59b5d242..e28279b39 100644 --- a/middleware/sunset.go +++ b/middleware/sunset.go @@ -12,7 +12,7 @@ import ( // route. Sunset alone is valid, but clients get no "stop using this" signal. // Middleware order doesn't matter. // -// sunsetAt is a future date, and should not be before the deprecation date. +// sunsetAt is usually a future date, and should not be before the deprecation date. // After it, remove the route or return 410 Gone. // It panics if sunsetAt is the zero time, which is usually an unset value. // Pass a real date, or skip the middleware when no date is set. diff --git a/middleware/sunset_test.go b/middleware/sunset_test.go index fc86b4d92..43624865f 100644 --- a/middleware/sunset_test.go +++ b/middleware/sunset_test.go @@ -81,6 +81,24 @@ func TestSunset(t *testing.T) { } }) + t.Run("Sunset with multiple links", func(t *testing.T) { + req, _ := http.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + + r := chi.NewRouter() + + docs := `; rel="sunset"` + next := `; rel="successor-version"` + r.Use(Sunset(time.Date(2025, 12, 24, 10, 20, 0, 0, time.UTC), docs, next)) + r.Get("/", func(w http.ResponseWriter, r *http.Request) {}) + r.ServeHTTP(w, req) + + got := w.Header().Values("Link") + if len(got) != 2 || got[0] != docs || got[1] != next { + t.Fatalf("Link = %q, want [%q %q]", got, docs, next) + } + }) + t.Run("Zero time panics", func(t *testing.T) { defer func() { if recover() == nil {