sleeper_api 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6fd4df8d68adb46808ea692bbf8c715f2218a07a8f93212f5efddfe404741a30
4
- data.tar.gz: cd3b09acb61407c634f04d76f165a1d2e0b7b840b3564117136ecc9343bc286c
3
+ metadata.gz: c89b9383f35e517efd9ce353f9c14b1463f37375cc7dc4c30310c5c57370cdab
4
+ data.tar.gz: 336f7a25653bdbad75ec3094d150389fb5abba4244ef80ffbe97ce702e6a7ce9
5
5
  SHA512:
6
- metadata.gz: a63777ce6255da38e963034bf9b016ca327d85dcaefc723ad508c85d58c9674939474ee00bd55b33b007e3268a043e81f8c175f06276cb77ae80b6a619871431
7
- data.tar.gz: aed8fa84f731ddb1e8a01ef2cfb8987305826f665f338954e990fcb8c2c946c3d33f73edf510217ecee1923412bc6470a56931dbd9c60ba7369230c2c847627c
6
+ metadata.gz: 49c0c6f2843e597c53cf8c8c548a348f7c03255e6a38306a8e43190523f2ad485125145e9dc844b4f6b1ea326b74286e8a26cffa34c8c8522caeb70822477f0b
7
+ data.tar.gz: 45c2ebd6bc9b2e9ef3cd4f5f53c6635cd00f8199f9f5da7d0061d740c99f27aa94a1894ac682d072fb542e5c0498996ba799d1ee8c094689281188befdce33f3
data/CHANGELOG.md CHANGED
@@ -1,3 +1,111 @@
1
+ ## [1.4.0] - 2026-09-14
2
+
3
+ ### Added
4
+
5
+ - **`Client#stats_with_context(season, week)` — one week's stat lines carrying
6
+ the week they were played in.** Each row has the player's team *that week*,
7
+ their `opponent`, the `game_id` and the `date`, which the existing `#stats`
8
+ endpoint does not return at all.
9
+
10
+ ⚠️ **It is served from a different host: `api.sleeper.com`, not
11
+ `api.sleeper.app`.** Same sport and season in the path, no `/v1`, and the
12
+ season type is a query parameter rather than a segment. The `.app` endpoint
13
+ answers the *same stat lines with none of this metadata*, which is how it sat
14
+ unfound through four epics of stats work in the consuming app.
15
+
16
+ **What is genuinely per-week, measured** over the 2,275 players present in
17
+ both week 1 and week 16 of 2021: `team` differs on **164** of them and
18
+ `opponent` on **2,262**. ⚠️ **The nested `player` object is not history** — it
19
+ is the current catalog record stapled on, with `team` null inside it and
20
+ `injury_status` differing on 1 of the 204 rows carrying one across those
21
+ fifteen weeks. A 2021 row reporting "Questionable" is reporting that he is
22
+ questionable *now*.
23
+
24
+ ⚠️ **Pass a week.** Dropping it answers season totals — 8,251 rows for 2021 —
25
+ with `week` and `opponent` null on every one, so the season form carries none
26
+ of the context the endpoint exists for. Shares `#stats`' traps otherwise:
27
+ nothing 404s, and `pre`/`post` restart week numbering. An array of rows, not
28
+ a hash keyed by player id.
29
+
30
+ ### Changed
31
+
32
+ - **`make_request` takes an optional `host:`**, which is how the second host is
33
+ reached. ⚠️ **Passing an absolute URL instead does not work**: HTTParty 0.24
34
+ raises `UnsafeURIError` for any URL whose host differs from the configured
35
+ `base_uri` — "this request could send credentials to an unintended server" —
36
+ so a second host has to arrive as its own per-request `base_uri`.
37
+
38
+ **Errors and logs now name the host whenever it is not the default.**
39
+ `/stats/nfl/2021/16` is a real path on both hosts and they return different
40
+ things, so a message quoting the path alone cannot say which one failed.
41
+ Messages for paths on `api.sleeper.app` are byte-for-byte what they were.
42
+
43
+ ## [1.3.1] - 2026-09-10
44
+
45
+ ### Fixed
46
+
47
+ - **Every request raised `ArgumentError: unknown keyword: quirks_mode` under
48
+ `json` 3.0.** HTTParty 0.24.2 parses with `JSON.parse(body, quirks_mode:
49
+ true, allow_nan: true)`; json 3.0 removed that keyword. Total breakage rather
50
+ than a degradation, and it arrives through a transitive bump rather than
51
+ anything a consumer chose — this gem has no committed lockfile, so CI went
52
+ red with no commit in between. httparty 0.24.2 is the newest release and has
53
+ no fix.
54
+
55
+ `SleeperApi::JsonParser` subclasses `HTTParty::Parser` and overrides its
56
+ `json` method to drop the flag; `Client` parses with it. **Deliberately not a
57
+ `json < 3` pin in the gemspec**, which would fix the same crash by forbidding
58
+ every app that uses this gem from upgrading json at all, for a flag none of
59
+ them asked for.
60
+
61
+ `quirks_mode: true` allowed a bare scalar at the top level, which is not
62
+ academic here — Sleeper answers an unknown username with a literal `null`.
63
+ Both majors parse that correctly without it: `JSON.parse("null")` is `nil`
64
+ under 2.21.2 and under 3.0.2. The suite passes under both.
65
+
66
+ ### Documented
67
+
68
+ Three facts measured against the live API on 2026-09-10, while NFL week 1 was
69
+ half-played — one game finished, one being played, fourteen not yet started.
70
+ No behaviour changed; all three are things a caller could previously only find
71
+ out by being wrong first.
72
+
73
+ - **`#schedule`'s `status` values are named: `pre_game`, `in_game`, `complete`,
74
+ `canceled`.** `in_game` was read off a live game; the other three come from
75
+ the published 2025 and 2026 schedules. It is the only per-game signal that a
76
+ game has been played — this payload has a `date` and no kickoff time, so a
77
+ caller can know a game is under way but never how far into it. **A week is
78
+ not one event**: week 1 of 2026 held three of those states at once, so
79
+ reasoning about "has the week started" from `date` alone gets it wrong for
80
+ everyone whose game is on Sunday. Treat the four as open and match with a
81
+ fallback — a postponement would be a fifth and none has been seen.
82
+
83
+ - **`#projections` is a pre-game, whole-game projection that does not move
84
+ while the game is played.** Five players in one night's game held the same
85
+ `pts_ppr` to the decimal across six hours spanning its kickoff; a game that
86
+ had already finished still projected 19.69 for a player who scored 26.2, so
87
+ it does not settle onto the final either. This is the natural endpoint to
88
+ reach for when building anything live, and it cannot answer the question:
89
+ **there is no live projection here**, so nothing in this payload says whether
90
+ a player is on pace. A player on 8 of a projected 12 in the first quarter is
91
+ ahead of schedule, and this reports 12 all afternoon.
92
+
93
+ - **`#stats` answers a week still being played with a partial set, and says
94
+ nothing about being partial.** Mid-week-1 it returned 301 rows with 42
95
+ carrying a `pts_ppr`. That is distinct from the already-documented empty
96
+ week: a caller treating "the week's stats" as the whole week gets a
97
+ half-filled answer for as long as the week is in progress, which for a
98
+ regular-season week is most of five days. `#schedule`'s per-game `status` is
99
+ the only thing that tells a missing row from a scoreless one.
100
+
101
+ ### Changed
102
+
103
+ - `#schedule`'s spec fixture carries all four statuses rather than `pre_game`
104
+ alone, and an example pins that they pass through untranslated. The narrow
105
+ fixture was a shape live data produces only on a quiet Tuesday, and a fixture
106
+ narrower than the API is how a caller ends up written against a vocabulary of
107
+ one.
108
+
1
109
  ## [1.3.0] - 2026-08-27
2
110
 
3
111
  ### Added
data/CLAUDE.md CHANGED
@@ -19,7 +19,7 @@ bundle exec rubocop -A # autocorrect
19
19
  bin/console # IRB with the gem loaded
20
20
  ```
21
21
 
22
- CI (`.github/workflows/ci.yml`) runs `bundle exec rake ci` on Ruby 3.2 only. The gemspec claims `required_ruby_version >= 2.6.0` and RuboCop targets 2.6, but nothing tests below 3.2 — treat 2.6 compatibility as unverified.
22
+ CI (`.github/workflows/ci.yml`) runs `bundle exec rake ci` on Ruby 3.2 and 3.4. The gemspec claims `required_ruby_version >= 2.6.0` and RuboCop targets 2.6, but nothing tests below 3.2 — treat 2.6 compatibility as unverified.
23
23
 
24
24
  ## Architecture
25
25
 
@@ -29,6 +29,27 @@ Four layers, with a deliberate split between HTTP and modeling:
29
29
  - **`Client`** — the only thing that talks HTTP. `include HTTParty` with `base_uri "https://api.sleeper.app"` — **the bare host; the `/v1` lives in each path**. That is deliberate and load-bearing: not every Sleeper endpoint is versioned. `/schedule/{sport}/{season_type}/{season}` is served from the host root, and while `base_uri` carried the version that endpoint was unreachable at any path a caller could pass in. A new endpoint spells out where it lives; do not move the version back into `base_uri` to shorten the paths.
30
30
 
31
31
  Every call funnels through the private `make_request`, which handles retry-on-timeout, logging, and converts non-2xx into `SleeperApi::Error`. **That error quotes the path**, so the path prefix is part of a public string — v1.2.0 changed it from `"Failed to fetch /user/x: 404"` to `"Failed to fetch /v1/user/x: 404"`. `Client` also owns the 24-hour in-memory player cache.
32
+
33
+ ⚠️ **There are two Sleeper hosts and v1.4.0 added the second.** `WEB_HOST`
34
+ (`https://api.sleeper.com`) is the one the web app uses, and it serves
35
+ **richer rows from same-looking paths**: `#stats_with_context` returns the
36
+ player's team *that week* and their opponent, while `#stats` on `.app`
37
+ returns the same stat lines with neither. Reached with `make_request(path,
38
+ host: WEB_HOST)` rather than by moving `base_uri`, because every other
39
+ endpoint lives on `.app`.
40
+
41
+ **Passing an absolute URL instead does not work**, and the failure is not
42
+ subtle: HTTParty 0.24 raises `UnsafeURIError` for any URL whose host differs
43
+ from the configured `base_uri` — *"this request could send credentials to an
44
+ unintended server"* — so a second host must arrive as its own per-request
45
+ `base_uri`. **The error and the log name the host whenever it is not the
46
+ default**, because `/stats/nfl/2021/16` is a real path on both and they
47
+ return different things; paths on `.app` quote exactly as they did before.
48
+
49
+ **When an endpoint almost has what you need, try the other host before
50
+ concluding the data does not exist.** Four epics of stats work in the
51
+ consuming app never surfaced the per-week team, because the documented-looking
52
+ path on `.app` answers 200 with a payload that simply omits it.
32
53
  - **`League` / `User` / `Draft`** — resource objects. Each takes `(id, client)`, fetches eagerly in the constructor, memoizes into ivars, and exposes formatted hashes.
33
54
  - **`Helpers`** — mixed into all four. `deep_symbolize_keys` plus `player_details`, which reaches through `@client` — so any class including it must define `@client`.
34
55
 
@@ -44,6 +65,22 @@ Four layers, with a deliberate split between HTTP and modeling:
44
65
 
45
66
  **Never fan out `get_players`.** It's a multi-megabyte payload of every NFL player. It's cached for 24h in an ivar on the client instance — which means the cache dies with the client. Consumers holding a short-lived client re-download it every time.
46
67
 
68
+ ### Undocumented endpoints carry undocumented vocabularies
69
+
70
+ Sleeper publishes no field reference, so every enum this gem passes through was
71
+ found by looking. Four are known — `#schedule`'s game `status`, `Player`'s
72
+ `status` and `injury_status`, and a roster's `lineup_position` — and **none of
73
+ them is closed**. Match with a fallback rather than a whitelist, and never
74
+ translate one into a vocabulary of this gem's own: the caller is the only one
75
+ who knows what to do with a value nobody has seen.
76
+
77
+ **When a value is measured, name it in the docstring and put the date and the
78
+ circumstance next to it.** `in_game` shipped as an unnamed fallback in the
79
+ consuming app for a day before anyone saw a live game; the fallback was right,
80
+ and the thing that made it a fact rather than a guess was writing down that it
81
+ was read at 21:13 on 2026-09-10 during a specific game. A vocabulary with no
82
+ provenance is indistinguishable from one somebody assumed.
83
+
47
84
  ## Testing
48
85
 
49
86
  RSpec + WebMock, with `WebMock.disable_net_connect!` — **specs must never hit the network**. Stub with `stub_request` (see `client_spec.rb`) or `instance_double(SleeperApi::Client)` for resource-object specs (see `league_spec.rb`).
@@ -54,6 +91,24 @@ RSpec + WebMock, with `WebMock.disable_net_connect!` — **specs must never hit
54
91
 
55
92
  `lib/` is at 100% line coverage. Every bug found in this gem so far has been in a `League#format_*` method handling a field Sleeper omitted, exercised only by real data. When adding a formatter, write the nil-field case first.
56
93
 
94
+ ### There is no committed Gemfile.lock, so CI resolves fresh every run
95
+
96
+ That is right for a library — a lockfile would hide exactly the incompatibility
97
+ a consumer is going to hit — but it has a consequence worth naming: **a green
98
+ run does not stay green.** A transitive release can turn CI red with no commit
99
+ in between, and the first PR opened afterwards looks like the culprit.
100
+
101
+ `json` 3.0 did this on 2026-09-11. It removed `quirks_mode`, which
102
+ `httparty` 0.24.2 passes on every JSON parse, so 11 examples began raising
103
+ `ArgumentError: unknown keyword: quirks_mode` — on a documentation-only PR that
104
+ touched no code. Check whether `main` is red before reading a failure as the
105
+ branch's fault; `main`'s last run can be weeks old.
106
+
107
+ `SleeperApi::JsonParser` is the fix, and it is worth reading before reaching for
108
+ a version pin: the gem overrides the one broken method rather than constraining
109
+ `json` in the gemspec, because a constraint there would forbid every consuming
110
+ app from upgrading `json` for a flag none of them asked for.
111
+
57
112
  ## Release
58
113
 
59
114
  Version lives in `lib/sleeper_api/version.rb`. Update `CHANGELOG.md`, then `bundle exec rake release` from a clean `main`, which builds to `pkg/`, tags, pushes the tag, and uploads to rubygems.
@@ -22,6 +22,17 @@ module SleeperApi
22
22
  # pass to make_request.
23
23
  base_uri "https://api.sleeper.app"
24
24
 
25
+ # ⚠️ **The other Sleeper host.** The web app talks to `api.sleeper.com`,
26
+ # which serves *richer rows from same-looking paths* — see
27
+ # #stats_with_context. Reached per-request rather than by moving base_uri,
28
+ # because every other endpoint here lives on `.app`.
29
+ WEB_HOST = "https://api.sleeper.com".freeze
30
+
31
+ # Not HTTParty's own. Its JSON branch passes `quirks_mode`, which json 3.0
32
+ # removed, so without this every response raises ArgumentError the moment a
33
+ # consumer resolves json 3. See JsonParser.
34
+ parser SleeperApi::JsonParser
35
+
25
36
  # @param config [SleeperApi::Configuration] Client configuration
26
37
  def initialize(config)
27
38
  @config = config
@@ -223,6 +234,14 @@ module SleeperApi
223
234
  # a legitimate answer and is indistinguishable from a typo. Validate the
224
235
  # arguments before you trust an empty body.
225
236
  #
237
+ # **A week still being played answers with a partial set, and says nothing
238
+ # about being partial.** Mid-week-1 of 2026 this returned 301 rows with
239
+ # only 42 carrying a `pts_ppr` — the two teams whose games had finished or
240
+ # started. A caller that treats "the week's stats" as the whole week gets a
241
+ # half-filled answer for as long as the week is in progress, which for a
242
+ # regular-season week is most of five days. #schedule's per-game `status`
243
+ # is the only thing that can tell a missing row from a scoreless one.
244
+ #
226
245
  # Omitting `week` requests season totals, which is a different resource at
227
246
  # a shorter path rather than a default of week 1.
228
247
  #
@@ -249,6 +268,20 @@ module SleeperApi
249
268
  # `adp_dd_ppr` 1000.0 and `pos_rank_*` 999.0 are "unknown" sentinels rather
250
269
  # than values.
251
270
  #
271
+ # ⚠️ **This is a pre-game, whole-game projection and it does not move while
272
+ # the game is played.** Measured on 2026-09-10 across six hours spanning a
273
+ # kickoff: five players in that night's game held the same `pts_ppr` before
274
+ # it started and while it was in progress, to the decimal. Checked again
275
+ # against a game that had already finished — the projection still read
276
+ # 19.69 for a player who had scored 26.2, so it does not settle onto the
277
+ # final either.
278
+ #
279
+ # That matters because it is the natural thing to reach for and the wrong
280
+ # one: **there is no live projection here**, so nothing in this payload can
281
+ # say whether a player is on pace. A player on 8 points of a projected 12
282
+ # in the first quarter is ahead of schedule, and this endpoint will report
283
+ # 12 all afternoon.
284
+ #
252
285
  # @param season [Integer, String] Season year, e.g. 2026
253
286
  # @param week [Integer, String, nil] Week number, or nil for season totals
254
287
  # @param season_type [String] "regular" (default), "pre", or "post"
@@ -258,6 +291,55 @@ module SleeperApi
258
291
  make_request(weekly_path("projections", sport, season_type, season, week))
259
292
  end
260
293
 
294
+ # Get one week's stat lines **with the context of the week they were played
295
+ # in** — the player's team *that week*, their opponent, the game and its
296
+ # date. Probed 2026-09-08, re-probed 2026-09-14.
297
+ #
298
+ # ⚠️ **A different host: `api.sleeper.com`, not `api.sleeper.app`.** The
299
+ # `.app` endpoint #stats calls returns the *same stat lines with none of
300
+ # this metadata*, which is why it can be there for years without anyone
301
+ # finding it. Same sport and season in the path, but no `/v1` and the
302
+ # season type is a query parameter rather than a segment.
303
+ #
304
+ # **What is genuinely per-week, measured** over the 2,275 players present in
305
+ # both week 1 and week 16 of 2021: `team` differs on **164** of them (a
306
+ # midseason trade is a real change of team) and `opponent` on **2,262**. So
307
+ # both are history rather than today's catalog.
308
+ #
309
+ # ⚠️ **The nested `player` object is NOT history.** It is the current
310
+ # catalog record stapled on — `player.team` is null inside it, and
311
+ # `injury_status` differed on 1 of the 204 rows carrying one across fifteen
312
+ # weeks, which is the signature of a field that does not vary by week at
313
+ # all. A 2021 row reporting "Questionable" is reporting that he is
314
+ # questionable *now*. The top-level `status` is null on every row seen.
315
+ #
316
+ # ⚠️ **Pass a week.** Dropping it answers season totals — 8,251 rows for
317
+ # 2021 — with `week` and `opponent` null on every one, so the season form
318
+ # carries none of the context this endpoint exists for.
319
+ #
320
+ # Shares #stats' traps: nothing 404s (an unplayed 2026 week, a week out of
321
+ # range, a season before Sleeper's history and a garbage season type all
322
+ # answer `200` with `[]`), and `pre`/`post` restart week numbering — 2021
323
+ # `post` week 1 is the wildcard round, played 2022-01-15.
324
+ #
325
+ # **An array, not a hash.** #stats keys by player id; this returns a list of
326
+ # rows each carrying `player_id`, so a caller indexing it must build its own
327
+ # map. `/projections/nfl/{season}/{week}` on the same host answers the same
328
+ # shape (9,420 rows for 2021 week 16) and has no wrapper here yet.
329
+ #
330
+ # @param season [Integer, String] Season year, e.g. 2021
331
+ # @param week [Integer, String] Week number — required, see above
332
+ # @param season_type [String] "regular" (default), "pre", or "post"
333
+ # @param sport [String] Sport code (default: "nfl")
334
+ # @return [HTTParty::Response] Array of stat lines, each with `team`,
335
+ # `opponent`, `game_id`, `date`, `week`, `season` and a nested `player`
336
+ def stats_with_context(season, week, season_type: "regular", sport: "nfl")
337
+ segments = [sport, season, week].map { |segment| ERB::Util.url_encode(segment.to_s) }
338
+ query = ERB::Util.url_encode(season_type.to_s)
339
+
340
+ make_request("/stats/#{segments.join("/")}?season_type=#{query}", host: WEB_HOST)
341
+ end
342
+
261
343
  # Get a season's game schedule.
262
344
  #
263
345
  # Undocumented, and served from the host root rather than /v1 — hence the
@@ -269,6 +351,26 @@ module SleeperApi
269
351
  # `post` (weeks 1-4) restart week numbering, so games from different season
270
352
  # types must never be pooled.
271
353
  #
354
+ # **`status` is `pre_game`, `in_game`, `complete` or `canceled`**, observed
355
+ # across the 2025 and 2026 regular seasons. `in_game` was read off a live
356
+ # game on 2026-09-10; the other three come from the published schedule. It
357
+ # is the only per-game signal of whether a game has been played — there is
358
+ # no kickoff time anywhere in this payload, only `date`, so a caller can
359
+ # know that a game is under way but never how far into it.
360
+ #
361
+ # **A week is not one event, and a caller reasoning from `date` alone will
362
+ # get that wrong.** Week 1 of 2026 held all three live states at the same
363
+ # moment: one `complete`, one `in_game`, fourteen `pre_game`.
364
+ #
365
+ # Treat the four as an open vocabulary. A postponement or a suspension
366
+ # would be a fifth value and none has been seen, so match with a fallback
367
+ # rather than a whitelist.
368
+ #
369
+ # **A team can carry two games in one week.** 2026 lists a canceled DAL/SEA
370
+ # in week 6 that was superseded rather than called off, and both teams play
371
+ # someone else that week. Order by status before picking one, or you will
372
+ # hand a team a bye it does not have.
373
+ #
272
374
  # A season Sleeper has not scheduled yet answers 200 with an empty array
273
375
  # rather than 404, so an empty result is a legitimate answer and not an
274
376
  # error.
@@ -336,29 +438,45 @@ module SleeperApi
336
438
 
337
439
  # Make an HTTP request with retry logic and logging.
338
440
  #
441
+ # ⚠️ **`host:` cannot be done by passing an absolute URL instead.** HTTParty
442
+ # 0.24 raises `UnsafeURIError` for any URL whose host differs from the
443
+ # configured `base_uri` — "this request could send credentials to an
444
+ # unintended server" — so a second host has to arrive as its own
445
+ # `base_uri`, which is a per-request option it supports.
446
+ #
447
+ # **The error and the log name the host whenever it is not the default.**
448
+ # `/stats/nfl/2021/16` is a real path on both hosts and they return
449
+ # different things, so a message quoting the path alone cannot say which
450
+ # one failed. Paths on the default host quote exactly as they always have.
451
+ #
339
452
  # @param path [String] API endpoint path
453
+ # @param host [String, nil] a different host, e.g. WEB_HOST
340
454
  # @return [HTTParty::Response]
341
455
  # @raise [SleeperApi::Error] On HTTP errors or timeouts
342
- def make_request(path)
343
- @config.logger&.info("Making request to #{self.class.base_uri}#{path}")
456
+ def make_request(path, host: nil)
457
+ named = host ? "#{host}#{path}" : path
458
+ options = { timeout: @config.timeout }
459
+ options[:base_uri] = host if host
460
+
461
+ @config.logger&.info("Making request to #{host || self.class.base_uri}#{path}")
344
462
  retries = 0
345
463
  begin
346
- response = self.class.get(path, timeout: @config.timeout)
464
+ response = self.class.get(path, **options)
347
465
  if response.success?
348
- @config.logger&.info("Successful response for #{path}")
466
+ @config.logger&.info("Successful response for #{named}")
349
467
  response
350
468
  else
351
- @config.logger&.error("Failed to fetch #{path}: #{response.code}")
352
- raise SleeperApi::Error, "Failed to fetch #{path}: #{response.code}"
469
+ @config.logger&.error("Failed to fetch #{named}: #{response.code}")
470
+ raise SleeperApi::Error, "Failed to fetch #{named}: #{response.code}"
353
471
  end
354
472
  rescue Net::OpenTimeout, Net::ReadTimeout => e
355
473
  retries += 1
356
474
  if retries <= @config.retries
357
- @config.logger&.warn("Retrying #{path} (attempt #{retries}/#{@config.retries}) due to #{e}")
475
+ @config.logger&.warn("Retrying #{named} (attempt #{retries}/#{@config.retries}) due to #{e}")
358
476
  sleep(1)
359
477
  retry
360
478
  else
361
- @config.logger&.error("Request timed out for #{path} after #{retries} retries")
479
+ @config.logger&.error("Request timed out for #{named} after #{retries} retries")
362
480
  raise SleeperApi::Error, "Request timed out after #{retries} retries"
363
481
  end
364
482
  end
@@ -0,0 +1,40 @@
1
+ require "httparty"
2
+ require "json"
3
+
4
+ module SleeperApi
5
+ # HTTParty's JSON parser, minus the one argument that stops it working.
6
+ #
7
+ # HTTParty 0.24.2 parses with `JSON.parse(body, quirks_mode: true,
8
+ # allow_nan: true)`. **json 3.0 removed `quirks_mode`**, so every response
9
+ # this gem parses raises `ArgumentError: unknown keyword: quirks_mode` the
10
+ # moment a consumer resolves json 3 — which is total breakage, not a
11
+ # degradation, and it arrives through a transitive bump nobody asked for.
12
+ # httparty 0.24.2 is the newest release and has no fix.
13
+ #
14
+ # **Dropping the option rather than pinning json is what keeps this the
15
+ # gem's problem instead of its consumers'.** A `json < 3` constraint in the
16
+ # gemspec would fix the same crash by forbidding every app that uses this
17
+ # gem from upgrading json at all, for a flag none of them asked for.
18
+ #
19
+ # `quirks_mode: true` allowed a bare scalar at the top level. That is not
20
+ # academic here — Sleeper answers an unknown username with a literal `null`.
21
+ # Both majors parse that correctly without the flag, checked on 2026-09-11:
22
+ # `JSON.parse("null")` is `nil` under json 2.21.2 and under 3.0.2. The flag
23
+ # has been doing nothing for this gem for some time.
24
+ #
25
+ # **@api private.** It is public only because `parser` has to name something
26
+ # a consumer's Ruby can resolve. Nothing here is a promise: it exists to be
27
+ # deleted the day httparty parses without the flag, and it is why 1.3.1 is a
28
+ # patch rather than a minor — a new constant that is not API does not earn
29
+ # one, and a fix for total breakage should reach a `~> 1.3.0` pin.
30
+ #
31
+ # A subclass rather than a `parser` lambda, because everything else in
32
+ # `HTTParty::Parser#parse` still applies — the blank-body guard, the format
33
+ # detection, the supported-format table. One method is wrong; one method is
34
+ # overridden.
35
+ class JsonParser < HTTParty::Parser
36
+ def json
37
+ JSON.parse(body, allow_nan: true)
38
+ end
39
+ end
40
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SleeperApi
4
- VERSION = "1.3.0"
4
+ VERSION = "1.4.0"
5
5
  end
data/lib/sleeper_api.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  require_relative "sleeper_api/version"
2
2
  require_relative "sleeper_api/helpers"
3
+ require_relative "sleeper_api/json_parser"
3
4
  require_relative "sleeper_api/client"
4
5
  require_relative "sleeper_api/league"
5
6
  require_relative "sleeper_api/user"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sleeper_api
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.0
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eruity1
@@ -148,6 +148,7 @@ files:
148
148
  - lib/sleeper_api/client.rb
149
149
  - lib/sleeper_api/draft.rb
150
150
  - lib/sleeper_api/helpers.rb
151
+ - lib/sleeper_api/json_parser.rb
151
152
  - lib/sleeper_api/league.rb
152
153
  - lib/sleeper_api/user.rb
153
154
  - lib/sleeper_api/version.rb