Skip to content

Answer a revisit 304 whether its ETag comes back weak or strong - #380

Merged
widgetii merged 2 commits into
masterfrom
boards-weak-etag
Oct 2, 2026
Merged

widgetii merged 2 commits into
masterfrom
boards-weak-etag

Conversation

@widgetii

@widgetii widgetii commented Oct 2, 2026

Copy link
Copy Markdown
Member

The bug. nginx's gzip turns the service's strong ETags into weak ones on the way out (W/"…"), and the browser sends back what it was given. Four places compared If-None-Match with their own ETag as an exact string:

  • the boards tree
  • the wizard
  • the builds explorer
  • httpx.WriteJSON

So every revisit through the front door got the whole body again, never a 304. The same request straight to the service got its 304, which is why only the conformance suite run against https://openipc.org caught it: TestTheBoardTreeIsJSONRevalidatedByETagAndSetsNothing fails there with a revisit with the ETag: 200.

Reproduced by hand:

  • curl -H 'Accept-Encoding: gzip' gets back ETag: W/"13c1…".
  • Sending that back as If-None-Match gets 200.
  • Without gzip, or direct to 127.0.0.1:3002, the same request gets 304.

The fix. httpx.ETagMatches does the weak comparison RFC 9110 (13.1.2) prescribes for If-None-Match:

  • W/ is ignored on both sides;
  • the header may list several tags;
  • * matches anything.

It walks the quoted tags rather than splitting on commas, since a comma is legal inside an entity-tag. All four handlers now use it. etag_test.go covers the case that failed, along with lists, * and malformed headers.

Checks. service/run.sh test passes for internal/httpx, boards, wizard and builds. Next: validation on dev through nginx (gzip revisit gets 304, plus the conformance suite), then production.

nginx's gzip turns the service's strong ETags weak on the way out (W/"..."),
and the browser sends back what it was given. The boards tree, the wizard,
the builds explorer and httpx.WriteJSON all compared If-None-Match with their
ETag as strings, so through the front door every revisit got the whole body
again -- while the same request straight to the service got its 304, which is
why only the conformance suite against openipc.org noticed
(TestTheBoardTreeIsJSONRevalidatedByETagAndSetsNothing).

httpx.ETagMatches does the weak comparison RFC 9110 prescribes for
If-None-Match -- W/ ignored on both sides, a list of tags, or * -- and the four
handlers use it.
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Restore 304 responses for weak and strong ETag revisits

🐞 Bug fix 🧪 Tests 🕐 20-40 Minutes

Grey Divider

AI Description

• Recognize ETags weakened by nginx gzip so revisits receive 304 responses instead of full bodies.
• Share RFC 9110 weak comparison across boards, wizard, builds, and JSON responses.
• Test weak and strong tags, lists, wildcards, and malformed headers.
Diagram

graph TD
  Client["Revisit request"] --> Proxy["nginx proxy"] --> Handlers["Conditional handlers"] --> Matcher["Weak ETag comparison"] --> Decision{"Tag matches?"} -->|yes| NotModified["304 response"]
  Decision -->|no| FullBody["200 JSON body"]
Loading
High-Level Assessment

A shared matcher is the appropriate fix: it applies the same If-None-Match semantics to all four paths without relying on nginx configuration. Disabling gzip would sacrifice compression, while separate handler fixes would duplicate parsing logic.

Files changed (5) +84 / -4

Bug fix (4) +47 / -4
api.goAccept weak ETags when revalidating boards responses +2/-1

Accept weak ETags when revalidating boards responses

• Replaces exact If-None-Match comparison with the shared matcher, allowing boards responses to return 304 when nginx weakened the client's ETag.

service/internal/boards/api.go

explorer.goUse weak ETag comparison for builds explorer responses +3/-1

Use weak ETag comparison for builds explorer responses

• Routes builds explorer revalidation through the shared matcher instead of requiring the request header to equal its generated ETag exactly.

service/internal/builds/explorer.go

httpx.goCentralize If-None-Match weak comparison +40/-1

Centralize If-None-Match weak comparison

• Adds ETagMatches to compare quoted entity-tags without regard to weak prefixes, recognize lists and wildcards, and reject malformed tags. WriteJSON now uses it for conditional responses.

service/internal/httpx/httpx.go

handler.goRecognize weak ETags on wizard revisits +2/-1

Recognize weak ETags on wizard revisits

• Uses the shared matcher for wizard responses so a client returning a proxy-weakened ETag can receive 304.

service/internal/wizard/handler.go

Tests (1) +37 / -0
etag_test.goTest ETag revalidation matching +37/-0

Test ETag revalidation matching

• Adds cases for strong and weak tags, tag lists, whitespace, wildcards, nonmatches, malformed headers, and commas within quoted tags.

service/internal/httpx/etag_test.go

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Missing resources return 304 to wildcard ✓ Resolved
Description
API.serve and Explorer.serve evaluate ETagMatches before their load callbacks, and the
matcher accepts If-None-Match: * unconditionally. A request for an unknown board model or builds
platform therefore returns 304 before the callback can establish that the resource should return
404.
Code

service/internal/boards/api.go[779]

+	if httpx.ETagMatches(r.Header.Get("If-None-Match"), etag) {
Evidence
The new matcher returns true for *; both changed call sites return 304 before reaching loaders
that produce 404 for missing records.

service/internal/httpx/httpx.go[175-187]
service/internal/boards/api.go[705-719]
service/internal/boards/api.go[775-794]
service/internal/builds/explorer.go[75-89]
service/internal/builds/explorer.go[367-375]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Wildcard matching now returns 304 before the boards and builds handlers determine whether the requested resource exists.
## Fix Focus Areas
- service/internal/boards/api.go[775-793]
- service/internal/builds/explorer.go[61-89]
- service/internal/httpx/httpx.go[180-187]
## Recommended Fix
Preserve the fast revalidation path for concrete tags, but verify that the requested representation exists before returning 304 for `*`. Add missing-resource tests with `If-None-Match: *` for both handlers.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Malformed validators can trigger a 304 ✓ Resolved
Description
ETagMatches returns true as soon as it sees * or a matching quoted tag, without validating what
follows that token or requiring a separator between tags. Consequently, If-None-Match: *junk and a
current tag followed by junk reach the 304 branch despite not being valid validator fields.
Code

service/internal/httpx/httpx.go[R196-197]

+		if s[:end+2] == want {
+			return true
Evidence
The wildcard branch returns without inspecting the suffix, and the matching-tag branch also returns
before inspecting characters following its closing quote; the existing malformed-header tests do not
exercise either case.

service/internal/httpx/httpx.go[180-200]
service/internal/httpx/etag_test.go[14-29]
service/internal/httpx/httpx.go[209-217]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The new parser accepts a valid-looking prefix even when the rest of the If-None-Match field is malformed, allowing that field to elicit 304.
## Fix Focus Areas
- service/internal/httpx/httpx.go[180-200]
- service/internal/httpx/etag_test.go[8-29]
## Recommended Fix
Parse and validate the complete field before reporting a match: require `*` to stand alone, require commas between entity-tags, and reject trailing text after a tag. Test malformed fields containing a matching prefix.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Split validators still get a full response ✓ Resolved
Description
All four updated call sites pass r.Header.Get("If-None-Match") to ETagMatches, so the matcher
receives only the first value when the request has multiple fields of that name. If a later field
contains the current tag, these handlers send a 200 response with the body instead of recognizing
the revisit.
Code

service/internal/httpx/httpx.go[212]

+	if ETagMatches(r.Header.Get("If-None-Match"), etag) {
Evidence
The matcher supports lists within its string argument, but every updated handler supplies
Header.Get, which returns one field value rather than the values available through
Header.Values.

service/internal/boards/api.go[779-779]
service/internal/builds/explorer.go[78-78]
service/internal/httpx/httpx.go[180-201]
service/internal/httpx/httpx.go[212-217]
service/internal/wizard/handler.go[71-74]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The new list-aware matcher sees only the first If-None-Match field value at each call site, missing matching tags in later values.
## Fix Focus Areas
- service/internal/boards/api.go[779-779]
- service/internal/builds/explorer.go[78-78]
- service/internal/httpx/httpx.go[212-212]
- service/internal/wizard/handler.go[71-71]
## Recommended Fix
Pass every `If-None-Match` field value to the matcher, or combine the values as a list before matching. Add a request test with a nonmatching first field and a matching second field.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can show, collapse, or hide each part of a finding: code, evidence, and all

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread service/internal/boards/api.go Outdated
Comment thread service/internal/httpx/httpx.go Outdated
Comment thread service/internal/httpx/httpx.go Outdated
…on *

- A request may carry If-None-Match more than once; httpx.Revisited reads
  every field as one list, where Header.Get saw only the first.
- A value that is not a well-formed list of entity-tags matches nothing, even
  when it holds the current tag: saying no costs only a full response.
- "*" matches nothing. Every handler decides 304 before it has looked the
  resource up, so it answered 304 for a board or platform that does not exist;
  no browser sends it on a GET.
@widgetii
widgetii merged commit 4c65727 into master Oct 2, 2026
4 checks passed
@widgetii
widgetii deleted the boards-weak-etag branch October 2, 2026 17:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant