`Last-Modified` / `If-Modified-Since` doesn't match the documented behaviour

Sort:
Avatar of Wallace_Wang

Hi,

I would like to report an in consistency regarding If-Modified-Since / Last-Modified between the API documentation and the actual behaviour.

The API documentation says:

"Each response has "ETag" and "Last-Modified" headers. If your client supplies the proper header in the request to prove that the data have not changed since your previous request ("If-None-Match" and "If-Modified-Since", respectively), then you will receive a 304 Not Modified response code, telling you that it is safe and correct to use your cached version."

ETag / If-None-Match works exactly as documented. If-Modified-Since / Last-Modified does not. Three distinct observations:

1. The origin ignores If-Modified-Since regardless of value

Probes below omit If-None-Match so only the date header is evaluated (RFC 7232 §6 gives ETag precedence when both are present):

# 1. Verbatim value from a prior Last-Modified response.
$ curl -s -o /dev/null -w '%{http_code}\n' \
    -H 'If-Modified-Since: Friday, 17-Apr-2026 11:05:26 GMT+0000' \
    https://api.chess.com/pub/player/snowycat
200

# 2. Same instant re-emitted in RFC 7231 IMF-fixdate form.
$ curl -s -o /dev/null -w '%{http_code}\n' \
    -H 'If-Modified-Since: Fri, 17 Apr 2026 11:05:26 GMT' \
    https://api.chess.com/pub/player/snowycat
200

# 3. A date 30 days in the FUTURE. Per RFC 7232 §3.3 the server MUST
#    return 304 here (the resource cannot have been modified since a
#    future instant).
$ curl -s -o /dev/null -w '%{http_code}\n' \
    -H 'If-Modified-Since: Sun, 17 May 2026 11:05:26 GMT' \
    https://api.chess.com/pub/player/snowycat
200

Same shape on /pub/player/{u}, /pub/player/{u}/stats, /pub/player/{u}/games/archives, and /pub/match/{id}. For comparison, If-None-Match with the same resource returns 304 with a matching ETag and 200 with a bogus one — so the conditional-GET path is alive, it just doesn't consult If-Modified-Since.

2. The Last-Modified value doesn't track content modification

snowycat is a closed account so the resource's content is static. The ETag W/"09ef210fc806b620027024c8ed241933" is byte-identical across multiple fetches separated by days. Yet the Last-Modified value reports a time from earlier this morning.

Same shape across a range of historic matches. Fetched a day apart, these ETags are all byte-identical:

/pub/match/85    W/"f2392d655d3950ca92ce842dd730a453"   last-modified: Thursday, 16-Apr-2026 17:07:4x GMT+0000
/pub/match/242   W/"8cf334e08ce33dd8f6c7b55d289561ad"   last-modified: Thursday, 16-Apr-2026 17:07:4x GMT+0000
/pub/match/1346  W/"8bd97ff4ab6a03f7da2ee0b8683229bd"   last-modified: Thursday, 16-Apr-2026 17:08:1x GMT+0000
/pub/match/3055  W/"e46352cbc0007bc81911e6d1b213b287"   last-modified: Thursday, 16-Apr-2026 17:16:3x GMT+0000
/pub/match/11495 W/"d88fe2ad3ef6d31644d16b712abeb551"   last-modified: Friday,   10-Apr-2026 13:18:19 GMT+0000
/pub/match/12    W/"4e2c65fc5eee4b2ecfbd66c2bc85cd73"   last-modified: Thursday, 16-Apr-2026 23:45:4x GMT+0000

These matches are all long-finished (match 12 is from Chess.com's earliest days). The ETag confirms the bodies haven't changed — yet Last-Modified values are scattered across the past week, not years ago. Whatever this value is tracking (edge-cache refresh time, perhaps), it isn't origin-resource modification.

The upshot: a client that honestly echoed the received timestamp back in If-Modified-Since would get 200s forever, because the server's idea of the resource's "modification time" on the next request is going to be later than any value it previously emitted.

3. The Last-Modified value isn't a valid HTTP-date

A typical response:

GET https://api.chess.com/pub/player/snowycat

etag: W/"09ef210fc806b620027024c8ed241933"
last-modified: Friday, 17-Apr-2026 11:05:26 GMT+0000
cache-control: public, max-age=5

RFC 7231 §7.1.1.1 defines exactly three legal HTTP-date forms:

Form Example
IMF-fixdate (preferred) Fri, 17 Apr 2026 11:05:26 GMT
RFC 850 (obsolete) Friday, 17-Apr-26 11:05:26 GMT
asctime Fri Apr 17 11:05:26 2026

The value Friday, 17-Apr-2026 11:05:26 GMT+0000 matches none:

  1. Full weekday name rules out IMF-fixdate and asctime.
  2. 4-digit year in hyphen-separated form rules out RFC 850, which mandates a 2-digit year.
  3. GMT+0000 isn't a legal HTTP-date timezone — RFC 7231 requires the literal string GMT.

Strict RFC 7231 parsers reject it outright (for example Java's DateTimeFormatter.RFC_1123_DATE_TIME throws DateTimeParseException, and typed-header wrappers built on it silently return None). More lenient parsers often parse successfully but drop the timezone — Python's email.utils.parsedate_to_datetime returns a naive datetime for this input versus a UTC-aware datetime for correctly-formed IMF-fixdate, a subtle footgun for code that does datetime arithmetic on the result.

Avatar of TrofAndy

what?

Avatar of Martin_Stahl

There are no plans on updating the current API and it's in maintenance mode. My understanding, is there are plans to redo the API in the future though

Avatar of Wallace_Wang
Martin_Stahl wrote:

There are no plans on updating the current API and it's in maintenance mode. My understanding, is there are plans to redo the API in the future though

Thanks Martin, that's good to know. Thank you and your team for all the good work

Avatar of BaronVonChickenpants
TrofAndy wrote:

what?


You are probably in the wrong group

Avatar of Neshaya-Mihelee

What

Avatar of ImperfectAge

Wow. Nice research. These findings are bugs, it means many of our API calls are unnecessary and causes a higher load on chess.com servers than needed.

Avatar of stephen_33
ImperfectAge wrote:

Wow. Nice research. These findings are bugs, it means many of our API calls are unnecessary and causes a higher load on chess.com servers than needed.

I started using Etags for the first time only last year and they do save a little time and resources when making multiple requests but I've never used the "If-Modified-Since / Last-Modified" header keys. I'm not sure why I'd ever need those.

But I can see how the purpose of those is completely wasted if false results are being given because of non-standard formats.

Several years ago I remember suggesting in this club that the clubs-matches-endpoint was split into "registration/in_progress" and "finished" because I often need the first set of matches but not the second and given how huge some club archives are that might save a lot of server time.

I was told then it would be forwarded to the developers but nothing came of it and now of course nothing ever will. I think we're stuck with the API as it is?

Avatar of IRL_Pwned

Is this Python?

I am a C/C++ Dev bro

Avatar of Wallace_Wang
stephen_33 wrote:
ImperfectAge wrote:

Wow. Nice research. These findings are bugs, it means many of our API calls are unnecessary and causes a higher load on chess.com servers than needed.

I started using Etags for the first time only last year and they do save a little time and resources when making multiple requests but I've never used the "If-Modified-Since / Last-Modified" header keys. I'm not sure why I'd ever need those.

But I can see how the purpose of those is completely wasted if false results are being given because of non-standard formats.

Several years ago I remember suggesting in this club that the clubs-matches-endpoint was split into "registration/in_progress" and "finished" because I often need the first set of matches but not the second and given how huge some club archives are that might save a lot of server time.

I was told then it would be forwarded to the developers but nothing came of it and now of course nothing ever will. I think we're stuck with the API as it is?

Fortunately when ETag is present, If-Modified-Since/Last-Modified should be ignored.

But we're very much stuck, certainly. To me the biggest flaw is still that players and clubs are identified only by unstable names, not idempotent ids. I have a workaround where I save match refs:

  • (club_id, match_id, is_live, is_team_1) for clubs
  • (player_id, match_id, is_live, is_team_1, board_index) for players

and tournament refs:

  • (player_id, tournament_id, round_1_position) for players

in case of name changes, but they only apply to those who've played matches/tournaments.