diff --git a/vllm/config.py b/vllm/config.py
index 2513d43ce8e6b..a0bd6b0975a16 100644
--- a/vllm/config.py
+++ b/vllm/config.py
@@ -11,7 +11,8 @@
 from vllm.model_executor.layers.quantization import QUANTIZATION_METHODS
 from vllm.model_executor.models import ModelRegistry
 from vllm.transformers_utils.config import get_config, get_hf_text_config
-from vllm.utils import get_cpu_memory, is_cpu, is_hip, is_neuron, is_tpu
+from vllm.utils import (cuda_device_count_stateless, get_cpu_memory, is_cpu,
+                        is_hip, is_neuron, is_tpu)

 if TYPE_CHECKING:
     from ray.util.placement_group import PlacementGroup
@@ -605,12 +606,11 @@ def __init__(
         if self.distributed_executor_backend is None and self.world_size > 1:
             # We use multiprocessing by default if world_size fits on the
             # current node and we aren't in a ray placement group.
-            from torch.cuda import device_count

             from vllm.executor import ray_utils
             backend = "mp"
             ray_found = ray_utils.ray is not None
-            if device_count() < self.world_size:
+            if cuda_device_count_stateless() < self.world_size:
                 if not ray_found:
                     raise ValueError("Unable to load Ray which is "
                                      "required for multi-node inference")
diff --git a/vllm/distributed/device_communicators/custom_all_reduce.py b/vllm/distributed/device_communicators/custom_all_reduce.py
index bbc2284f8a364..2f8ffe87d4809 100644
--- a/vllm/distributed/device_communicators/custom_all_reduce.py
+++ b/vllm/distributed/device_communicators/custom_all_reduce.py
@@ -12,6 +12,7 @@
 from vllm.distributed.parallel_state import (
     get_local_rank, get_tensor_model_parallel_cpu_group, is_in_the_same_node)
 from vllm.logger import init_logger
+from vllm.utils import cuda_device_count_stateless

 try:
     import pynvml
@@ -149,7 +150,7 @@ def __init__(self,
         if cuda_visible_devices:
             device_ids = list(map(int, cuda_visible_devices.split(",")))
         else:
-            device_ids = list(range(torch.cuda.device_count()))
+            device_ids = list(range(cuda_device_count_stateless()))

         physical_device_id = device_ids[device.index]
         tensor = torch.tensor([physical_device_id],
diff --git a/vllm/distributed/device_communicators/custom_all_reduce_utils.py b/vllm/distributed/device_communicators/custom_all_reduce_utils.py
index 4b89a23dfc463..b3d397de72cc1 100644
--- a/vllm/distributed/device_communicators/custom_all_reduce_utils.py
+++ b/vllm/distributed/device_communicators/custom_all_reduce_utils.py
@@ -13,6 +13,7 @@
 import vllm.envs as envs
 from vllm.distributed.parallel_state import get_cpu_world_group, get_local_rank
 from vllm.logger import init_logger
+from vllm.utils import cuda_device_count_stateless

 logger = init_logger(__name__)

@@ -153,7 +154,7 @@ def gpu_p2p_access_check(i: int, j: int) -> bool:

     is_distributed = dist.is_initialized()

-    num_dev = torch.cuda.device_count()
+    num_dev = cuda_device_count_stateless()
     cuda_visible_devices = envs.CUDA_VISIBLE_DEVICES
     if cuda_visible_devices is None:
         cuda_visible_devices = ",".join(str(i) for i in range(num_dev))
diff --git a/vllm/executor/multiproc_gpu_executor.py b/vllm/executor/multiproc_gpu_executor.py
index 99c9e52034cc1..8385e56f88b39 100644
--- a/vllm/executor/multiproc_gpu_executor.py
+++ b/vllm/executor/multiproc_gpu_executor.py
@@ -9,7 +9,8 @@
                                                   ResultHandler, WorkerMonitor)
 from vllm.logger import init_logger
 from vllm.sequence import ExecuteModelRequest, SamplerOutput
-from vllm.utils import (get_distributed_init_method, get_ip, get_open_port,
+from vllm.utils import (cuda_device_count_stateless,
+                        get_distributed_init_method, get_ip, get_open_port,
                         get_vllm_instance_id, make_async)

 logger = init_logger(__name__)
@@ -33,8 +34,7 @@ def _init_executor(self) -> None:
         # Disable torch async compiling which won't work with daemonic processes
         os.environ["TORCHINDUCTOR_COMPILE_THREADS"] = "1"

-        from torch.cuda import device_count
-        assert world_size <= device_count(), (
+        assert world_size <= cuda_device_count_stateless(), (
             "please set tensor_parallel_size to less than max local gpu count")

         distributed_init_method = get_distributed_init_method(
diff --git a/vllm/utils.py b/vllm/utils.py
index af585929d1a09..b5c42605ba358 100644
--- a/vllm/utils.py
+++ b/vllm/utils.py
@@ -693,3 +693,38 @@ def inner(*args, **kwargs):
         return inner  # type: ignore

     return wrapper
+
+
+@lru_cache(maxsize=8)
+def _cuda_device_count_stateless(
+        cuda_visible_devices: Optional[str] = None) -> int:
+    # Note: cuda_visible_devices is not used, but we keep it as an argument for
+    # LRU Cache purposes.
+
+    # Code below is based on
+    # https://github.com/pytorch/pytorch/blob/
+    # c1cd946818442aca8c7f812b16d187ce1586c3bc/
+    # torch/cuda/__init__.py#L831C1-L831C17
+    import torch.cuda
+    import torch.version
+
+    if not torch.cuda._is_compiled():
+        return 0
+    # bypass _device_count_nvml() if rocm (not supported)
+    nvml_count = -1 if torch.version.hip else torch.cuda._device_count_nvml()
+    r = torch._C._cuda_getDeviceCount() if nvml_count < 0 else nvml_count
+    return r
+
+
+def cuda_device_count_stateless() -> int:
+    """Get number of CUDA devices, caching based on the value of
+    CUDA_VISIBLE_DEVICES at the time of call.
+
+    This should be used instead of torch.cuda.device_count()
+    unless CUDA_VISIBLE_DEVICES has already been set to the desired
+    value."""
+
+    # This can be removed and simply replaced with torch.cuda.get_device_count
+    # after https://github.com/pytorch/pytorch/pull/122815 is released.
+
+    return _cuda_device_count_stateless(envs.CUDA_VISIBLE_DEVICES)

diff --git a/.buildkite/test-pipeline.yaml b/.buildkite/test-pipeline.yaml
index 6b12d19ba611f..6a2932db9f2dc 100644
--- a/.buildkite/test-pipeline.yaml
+++ b/.buildkite/test-pipeline.yaml
@@ -48,6 +48,7 @@ steps:
   - TEST_DIST_MODEL=meta-llama/Llama-2-7b-hf DISTRIBUTED_EXECUTOR_BACKEND=mp pytest -v -s distributed/test_chunked_prefill_distributed.py
   - pytest -v -s spec_decode/e2e/test_integration_dist.py
   - CUDA_VISIBLE_DEVICES=0,1 pytest -v -s test_sharded_state_loader.py
+  - CUDA_VISIBLE_DEVICES=0,1 pytest -v -s distributed/test_utils.py

 - label: Distributed Tests (Multiple Groups)
   #mirror_hardwares: [amd]
diff --git a/tests/conftest.py b/tests/conftest.py
index e0680467d78b9..5e482466e1c64 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,8 +1,6 @@
 import contextlib
 import gc
 import os
-import subprocess
-import sys
 from typing import Any, Dict, List, Optional, Tuple, TypeVar

 import pytest
@@ -21,7 +19,7 @@
 from vllm.multimodal import MultiModalData
 from vllm.multimodal.image import ImageFeatureData, ImagePixelData
 from vllm.sequence import SampleLogprobs
-from vllm.utils import is_cpu
+from vllm.utils import cuda_device_count_stateless, is_cpu

 logger = init_logger(__name__)

@@ -537,15 +535,4 @@ def num_gpus_available():
     """Get number of GPUs without initializing the CUDA context
     in current process."""

-    try:
-        out = subprocess.run([
-            sys.executable, "-c",
-            "import torch; print(torch.cuda.device_count())"
-        ],
-                             capture_output=True,
-                             check=True,
-                             text=True)
-    except subprocess.CalledProcessError as e:
-        logger.warning("Failed to get number of GPUs.", exc_info=e)
-        return 0
-    return int(out.stdout.strip())
+    return cuda_device_count_stateless()
diff --git a/tests/distributed/test_utils.py b/tests/distributed/test_utils.py
new file mode 100644
index 0000000000000..b7ec59c7a2cc6
--- /dev/null
+++ b/tests/distributed/test_utils.py
@@ -0,0 +1,31 @@
+import os
+
+import ray
+
+from vllm.utils import cuda_device_count_stateless
+
+
+@ray.remote
+class _CUDADeviceCountStatelessTestActor():
+
+    def get_count(self):
+        return cuda_device_count_stateless()
+
+    def set_cuda_visible_devices(self, cuda_visible_devices: str):
+        os.environ["CUDA_VISIBLE_DEVICES"] = cuda_visible_devices
+
+    def get_cuda_visible_devices(self):
+        return os.environ["CUDA_VISIBLE_DEVICES"]
+
+
+def test_cuda_device_count_stateless():
+    """Test that cuda_device_count_stateless changes return value if
+    CUDA_VISIBLE_DEVICES is changed."""
+
+    actor = _CUDADeviceCountStatelessTestActor.options(num_gpus=2).remote()
+    assert ray.get(actor.get_cuda_visible_devices.remote()) == "0,1"
+    assert ray.get(actor.get_count.remote()) == 2
+    ray.get(actor.set_cuda_visible_devices.remote("0"))
+    assert ray.get(actor.get_count.remote()) == 1
+    ray.get(actor.set_cuda_visible_devices.remote(""))
+    assert ray.get(actor.get_count.remote()) == 0

[Usage]: Is it possible to start 8 tp=1 LLMEngine on a 8-GPU machine?
### Your current environment

```text
Collecting environment information...
/home/corvo/.local/lib/python3.10/site-packages/transformers/utils/hub.py:124: FutureWarning: Using `TRANSFORMERS_CACHE` is deprecated and will be removed in v5 of Transformers. Use `HF_HOME` instead.
  warnings.warn(
PyTorch version: 2.3.0+cu121
Is debug build: False
CUDA used to build PyTorch: 12.1
ROCM used to build PyTorch: N/A

OS: Ubuntu 22.04.4 LTS (x86_64)
GCC version: (Ubuntu 11.4.0-1ubuntu1~22.04) 11.4.0
Clang version: Could not collect
CMake version: version 3.28.3
Libc version: glibc-2.35

Python version: 3.10.12 (main, Nov 20 2023, 15:14:05) [GCC 11.4.0] (64-bit runtime)
Python platform: Linux-5.10.213-201.855.amzn2.x86_64-x86_64-with-glibc2.35
Is CUDA available: True
CUDA runtime version: 12.4.99
CUDA_MODULE_LOADING set to: LAZY
GPU models and configuration:
GPU 0: NVIDIA A100-SXM4-40GB
GPU 1: NVIDIA A100-SXM4-40GB
GPU 2: NVIDIA A100-SXM4-40GB
GPU 3: NVIDIA A100-SXM4-40GB
GPU 4: NVIDIA A100-SXM4-40GB
GPU 5: NVIDIA A100-SXM4-40GB
GPU 6: NVIDIA A100-SXM4-40GB
GPU 7: NVIDIA A100-SXM4-40GB

Nvidia driver version: 535.161.08
cuDNN version: Probably one of the following:
/usr/lib/x86_64-linux-gnu/libcudnn.so.9.0.0
/usr/lib/x86_64-linux-gnu/libcudnn_adv.so.9.0.0
/usr/lib/x86_64-linux-gnu/libcudnn_cnn.so.9.0.0
/usr/lib/x86_64-linux-gnu/libcudnn_engines_precompiled.so.9.0.0
/usr/lib/x86_64-linux-gnu/libcudnn_engines_runtime_compiled.so.9.0.0
/usr/lib/x86_64-linux-gnu/libcudnn_graph.so.9.0.0
/usr/lib/x86_64-linux-gnu/libcudnn_heuristic.so.9.0.0
/usr/lib/x86_64-linux-gnu/libcudnn_ops.so.9.0.0
HIP runtime version: N/A
MIOpen runtime version: N/A
Is XNNPACK available: True

CPU:
Architecture:                       x86_64
CPU op-mode(s):                     32-bit, 64-bit
Address sizes:                      46 bits physical, 48 bits virtual
Byte Order:                         Little Endian
CPU(s):                             96
On-line CPU(s) list:                0-95
Vendor ID:                          GenuineIntel
Model name:                         Intel(R) Xeon(R) Platinum 8275CL CPU @ 3.00GHz
CPU family:                         6
Model:                              85
Thread(s) per core:                 2
Core(s) per socket:                 24
Socket(s):                          2
Stepping:                           7
BogoMIPS:                           5999.99
Flags:                              fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ss ht syscall nx pdpe1gb rdtscp lm constant_tsc arch_perfmon rep_good nopl xtopology nonstop_tsc cpuid aperfmperf tsc_known_freq pni pclmulqdq monitor ssse3 fma cx16 pcid sse4_1 sse4_2 x2apic movbe popcnt tsc_deadline_timer aes xsave avx f16c rdrand hypervisor lahf_lm abm 3dnowprefetch invpcid_single pti fsgsbase tsc_adjust bmi1 avx2 smep bmi2 erms invpcid mpx avx512f avx512dq rdseed adx smap clflushopt clwb avx512cd avx512bw avx512vl xsaveopt xsavec xgetbv1 xsaves ida arat pku ospke
Hypervisor vendor:                  KVM
Virtualization type:                full
L1d cache:                          1.5 MiB (48 instances)
L1i cache:                          1.5 MiB (48 instances)
L2 cache:                           48 MiB (48 instances)
L3 cache:                           71.5 MiB (2 instances)
NUMA node(s):                       2
NUMA node0 CPU(s):                  0-23,48-71
NUMA node1 CPU(s):                  24-47,72-95
Vulnerability Gather data sampling: Unknown: Dependent on hypervisor status
Vulnerability Itlb multihit:        KVM: Mitigation: VMX unsupported
Vulnerability L1tf:                 Mitigation; PTE Inversion
Vulnerability Mds:                  Vulnerable: Clear CPU buffers attempted, no microcode; SMT Host state unknown
Vulnerability Meltdown:             Mitigation; PTI
Vulnerability Mmio stale data:      Vulnerable: Clear CPU buffers attempted, no microcode; SMT Host state unknown
Vulnerability Retbleed:             Vulnerable
Vulnerability Spec rstack overflow: Not affected
Vulnerability Spec store bypass:    Vulnerable
Vulnerability Spectre v1:           Mitigation; usercopy/swapgs barriers and __user pointer sanitization
Vulnerability Spectre v2:           Mitigation; Retpolines, STIBP disabled, RSB filling
Vulnerability Srbds:                Not affected
Vulnerability Tsx async abort:      Not affected

Versions of relevant libraries:
[pip3] numpy==1.24.4
[pip3] nvidia-nccl-cu12==2.20.5
[pip3] onnx==1.15.0rc2
[pip3] optree==0.10.0
[pip3] pytorch-quantization==2.1.2
[pip3] pytorch-triton==2.2.0+e28a256d7
[pip3] torch==2.3.0
[pip3] torch-tensorrt==2.3.0a0
[pip3] torchdata==0.7.1a0
[pip3] torchtext==0.17.0a0
[pip3] torchvision==0.18.0a0
[pip3] triton==2.3.0
[pip3] vllm-nccl-cu12==2.18.1.0.4.0
[conda] Could not collectROCM Version: Could not collect
Neuron SDK Version: N/A
vLLM Version: 0.4.2
vLLM Build Flags:
CUDA Archs: 5.2 6.0 6.1 7.0 7.2 7.5 8.0 8.6 8.7 9.0+PTX; ROCm: Disabled; Neuron: Disabled
GPU Topology:
GPU0	GPU1	GPU2	GPU3	GPU4	GPU5	GPU6	GPU7	CPU Affinity	NUMA Affinity	GPU NUMA ID
GPU0	 X 	NV12	NV12	NV12	NV12	NV12	NV12	NV12	0-23,48-71	0		N/A
GPU1	NV12	 X 	NV12	NV12	NV12	NV12	NV12	NV12	0-23,48-71	0		N/A
GPU2	NV12	NV12	 X 	NV12	NV12	NV12	NV12	NV12	0-23,48-71	0		N/A
GPU3	NV12	NV12	NV12	 X 	NV12	NV12	NV12	NV12	0-23,48-71	0		N/A
GPU4	NV12	NV12	NV12	NV12	 X 	NV12	NV12	NV12	24-47,72-95	1		N/A
GPU5	NV12	NV12	NV12	NV12	NV12	 X 	NV12	NV12	24-47,72-95	1		N/A
GPU6	NV12	NV12	NV12	NV12	NV12	NV12	 X 	NV12	24-47,72-95	1		N/A
GPU7	NV12	NV12	NV12	NV12	NV12	NV12	NV12	 X 	24-47,72-95	1		N/A

Legend:

  X    = Self
  SYS  = Connection traversing PCIe as well as the SMP interconnect between NUMA nodes (e.g., QPI/UPI)
  NODE = Connection traversing PCIe as well as the interconnect between PCIe Host Bridges within a NUMA node
  PHB  = Connection traversing PCIe as well as a PCIe Host Bridge (typically the CPU)
  PXB  = Connection traversing multiple PCIe bridges (without traversing the PCIe Host Bridge)
  PIX  = Connection traversing at most a single PCIe bridge
  NV#  = Connection traversing a bonded set of # NVLinks
```

### How would you like to use vllm

Below code will try to init LLM on the 1st GPU, causing GPU OOM.
```
from vllm import LLM

for i in range(8):
    llm = LLM(
        model="/models/mistral-7b",
        tensor_parallel_size=1,
    )
```
[Usage]: How to start vLLM on a particular GPU?
### Your current environment

```
Collecting environment information...
PyTorch version: 2.3.0+cu121
Is debug build: False
CUDA used to build PyTorch: 12.1
ROCM used to build PyTorch: N/A

OS: Ubuntu 20.04.6 LTS (x86_64)
GCC version: (Ubuntu 9.4.0-1ubuntu1~20.04.2) 9.4.0
Clang version: Could not collect
CMake version: version 3.29.3
Libc version: glibc-2.31

Python version: 3.11.9 (main, Apr 19 2024, 16:48:06) [GCC 11.2.0] (64-bit runtime)
Python platform: Linux-5.15.0-1056-azure-x86_64-with-glibc2.31
Is CUDA available: True
CUDA runtime version: 11.8.89
CUDA_MODULE_LOADING set to: LAZY
GPU models and configuration:
GPU 0: NVIDIA A100 80GB PCIe
GPU 1: NVIDIA A100 80GB PCIe

Nvidia driver version: 545.23.08
cuDNN version: Probably one of the following:
/usr/local/cuda-11.8/targets/x86_64-linux/lib/libcudnn.so.8.7.0
/usr/local/cuda-11.8/targets/x86_64-linux/lib/libcudnn_adv_infer.so.8.7.0
/usr/local/cuda-11.8/targets/x86_64-linux/lib/libcudnn_adv_train.so.8.7.0
/usr/local/cuda-11.8/targets/x86_64-linux/lib/libcudnn_cnn_infer.so.8.7.0
/usr/local/cuda-11.8/targets/x86_64-linux/lib/libcudnn_cnn_train.so.8.7.0
/usr/local/cuda-11.8/targets/x86_64-linux/lib/libcudnn_ops_infer.so.8.7.0
/usr/local/cuda-11.8/targets/x86_64-linux/lib/libcudnn_ops_train.so.8.7.0
HIP runtime version: N/A
MIOpen runtime version: N/A
Is XNNPACK available: True

CPU:
Architecture:                       x86_64
CPU op-mode(s):                     32-bit, 64-bit
Byte Order:                         Little Endian
Address sizes:                      48 bits physical, 48 bits virtual
CPU(s):                             48
On-line CPU(s) list:                0-47
Thread(s) per core:                 1
Core(s) per socket:                 48
Socket(s):                          1
NUMA node(s):                       2
Vendor ID:                          AuthenticAMD
CPU family:                         25
Model:                              1
Model name:                         AMD EPYC 7V13 64-Core Processor
Stepping:                           1
CPU MHz:                            2445.437
BogoMIPS:                           4890.87
Hypervisor vendor:                  Microsoft
Virtualization type:                full
L1d cache:                          1.5 MiB
L1i cache:                          1.5 MiB
L2 cache:                           24 MiB
L3 cache:                           192 MiB
NUMA node0 CPU(s):                  0-23
NUMA node1 CPU(s):                  24-47
Vulnerability Gather data sampling: Not affected
Vulnerability Itlb multihit:        Not affected
Vulnerability L1tf:                 Not affected
Vulnerability Mds:                  Not affected
Vulnerability Meltdown:             Not affected
Vulnerability Mmio stale data:      Not affected
Vulnerability Retbleed:             Not affected
Vulnerability Spec rstack overflow: Mitigation; safe RET, no microcode
Vulnerability Spec store bypass:    Vulnerable
Vulnerability Spectre v1:           Mitigation; usercopy/swapgs barriers and __user pointer sanitization
Vulnerability Spectre v2:           Mitigation; Retpolines, STIBP disabled, RSB filling, PBRSB-eIBRS Not affected
Vulnerability Srbds:                Not affected
Vulnerability Tsx async abort:      Not affected
Flags:                              fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ht syscall nx mmxext fxsr_opt pdpe1gb rdtscp lm constant_tsc rep_good nopl tsc_reliable nonstop_tsc cpuid extd_apicid aperfmperf pni pclmulqdq ssse3 fma cx16 pcid sse4_1 sse4_2 movbe popcnt aes xsave avx f16c rdrand hypervisor lahf_lm cmp_legacy cr8_legacy abm sse4a misalignsse 3dnowprefetch osvw topoext perfctr_core invpcid_single vmmcall fsgsbase bmi1 avx2 smep bmi2 erms invpcid rdseed adx smap clflushopt clwb sha_ni xsaveopt xsavec xgetbv1 xsaves clzero xsaveerptr rdpru arat umip vaes vpclmulqdq rdpid fsrm

Versions of relevant libraries:
[pip3] numpy==1.26.4
[pip3] nvidia-nccl-cu12==2.20.5
[pip3] torch==2.3.0
[pip3] triton==2.3.0
[pip3] vllm_nccl_cu12==2.18.1.0.4.0
[conda] numpy                     1.26.4                   pypi_0    pypi
[conda] nvidia-nccl-cu12          2.20.5                   pypi_0    pypi
[conda] torch                     2.3.0                    pypi_0    pypi
[conda] triton                    2.3.0                    pypi_0    pypi
[conda] vllm-nccl-cu12            2.18.1.0.4.0             pypi_0    pypiROCM Version: Could not collect
Neuron SDK Version: N/A
vLLM Version: 0.4.2
vLLM Build Flags:
CUDA Archs: Not Set; ROCm: Disabled; Neuron: Disabled
GPU Topology:
GPU0    GPU1    NIC0    CPU Affinity    NUMA Affinity   GPU NUMA ID
GPU0     X      NV12    SYS     0-23    0               N/A
GPU1    NV12     X      SYS     24-47   1               N/A
NIC0    SYS     SYS      X

Legend:

  X    = Self
  SYS  = Connection traversing PCIe as well as the SMP interconnect between NUMA nodes (e.g., QPI/UPI)
  NODE = Connection traversing PCIe as well as the interconnect between PCIe Host Bridges within a NUMA node
  PHB  = Connection traversing PCIe as well as a PCIe Host Bridge (typically the CPU)
  PXB  = Connection traversing multiple PCIe bridges (without traversing the PCIe Host Bridge)
  PIX  = Connection traversing at most a single PCIe bridge
  NV#  = Connection traversing a bonded set of # NVLinks

NIC Legend:

  NIC0: mlx5_0
```

### How would you like to use vllm

I have two GPUs in my VM... I am already using vLLM on one of the GPUs and the other one is vacant.
How can I start a second vLLM instance on the second GPU of mine?

I tried:
```bash
--device cuda    |    --device auto    |    --device cuda:1
```
but they don't seem to work as I was expecting...

Could you please tell me what am I missing here?

Regards!

Hi @sfc-gh-zhwang, I don't think this is easily doable in vLLM at the moment within a single python process. Possibly you could construct each model on GPU 0 and move each to GPU X before moving on.
I would recommend starting a separate process for each LLM and specifying `CUDA_VISIBLE_DEVICES` for each i.e. `CUDA_VISIBLE_DEVICES=0 python script.py`, `CUDA_VISIBLE_DEVICES=1 python script.py`, etc
Vllm always use cuda:0. That's bad. Same with #4981
You can use `CUDA_VISIBLE_DEVICES` environment variable when running the command.
I changed CUDA_VISIBLE_DEVICES, and when I delete CUDA_VISIBLE_DEVICES to load another model. I got an error: CUDA error: invalid device ordinal.
> I changed CUDA_VISIBLE_DEVICES, and when I delete CUDA_VISIBLE_DEVICES to load another model. I got an error: CUDA error: invalid device ordinal.

Can you show the commands (including env variables) which you used to run vLLM?
> > 我更改了CUDA_VISIBLE_DEVICES，当我删除CUDA_VISIBLE_DEVICES以加载另一个模型时。我收到错误：CUDA 错误：设备序号无效。
>
> 您能展示用于运行 vLLM 的命令（包括 env 变量）吗？

I use an script to select GPU of most memory. So I have to del CUDA_VISIBLE_DEVICES env variable after I load a model, and then to load another model. However, When I move new model to the device I select. I got the error.
Actually, I think this bug is not caused by vllm. Even I don't use vllm, when I set CUDA_VISIBLE_DEVICES and then unset CUDA_VISIBLE_DEVICES to load another model, I will got an error. I don't think set CUDA_VISIBLE_DEVICES  is a good way to set GPU.
> > 我更改了CUDA_VISIBLE_DEVICES，当我删除CUDA_VISIBLE_DEVICES以加载另一个模型时。我收到错误：CUDA 错误：设备序号无效。
>
> 您能展示用于运行 vLLM 的命令（包括 env 变量）吗？

It appears that if you set the CUDA_VISIBLE_DEVICES environment variable, for example, os.environ["CUDA_VISIBLE_DEVICES"] = "2,3", then in your code, the device indices will start from 0. That is, cuda:0 corresponds to the actual cuda:2, and cuda:1 corresponds to the actual cuda:3
> > > 我更改了CUDA_VISIBLE_DEVICES，当我删除CUDA_VISIBLE_DEVICES以加载另一个模型时。我收到错误：CUDA 错误：设备序号无效。
> >
> >
> > 您能展示用于运行 vLLM 的命令（包括 env 变量）吗？
>
> It appears that if you set the CUDA_VISIBLE_DEVICES environment variable, for example, os.environ["CUDA_VISIBLE_DEVICES"] = "2,3", then in your code, the device indices will start from 0. That is, cuda:0 corresponds to the actual cuda:2, and cuda:1 corresponds to the actual cuda:3

Usually, I set the environment variable in the command line instead of inside Python, e.g.:

```
CUDA_VISIBLE_DEVICES=0,1 python -m <command>
```

This is because the environment variable needs to be updated before importing PyTorch in order for it to properly take effect, which is difficult to rely on.
> > > > 我更改了CUDA_VISIBLE_DEVICES，当我删除CUDA_VISIBLE_DEVICES以加载另一个模型时。我收到错误：CUDA 错误：设备序号无效。
> > >
> > >
> > > 您能展示用于运行 vLLM 的命令（包括 env 变量）吗？
> >
> >
> > 如果您设置了CUDA_VISIBLE_DEVICES环境变量，例如 os.environ[“CUDA_VISIBLE_DEVICES”] = “2,3”，那么在您的代码中，设备索引将从 0 开始。也就是说，cuda：0 对应于实际的 cuda：2，而 cuda：1 对应于实际的 cuda：3
>
> 通常，我在命令行中而不是在 Python 中设置环境变量，例如：
>
> ```
> CUDA_VISIBLE_DEVICES=0,1 python -m <command>
> ```
>
> 这是因为在导入 PyTorch 之前需要更新环境变量才能使其正确生效，这很难依赖。

I have several model and gpu. So I have to set CUDA_VISIBLE_DEVICES several times, and get error. Set CUDA_VISIBLE_DEVICES is not a good way. I think when people have several model and gpu, they need a device paramter.
You can run multiple vLLM commands simultaneously, each with a different GPU.
I have decided not to use vllm. Vllm has a DeviceConfig configuration, and you can pass a device
paramter to vllm.LLM. but the kv-cache does not use it and always uses cuda:0. This is too messy.


diff --git a/benchmarks/opacities_opacity.py b/benchmarks/opacities_opacity.py
index 5352ceccc89..7589632aabd 100644
--- a/benchmarks/opacities_opacity.py
+++ b/benchmarks/opacities_opacity.py
@@ -28,7 +28,7 @@ def time_photoabsorption_opacity_calculation(self):
         )

     def time_pair_creation_opacity_calculation(self):
-        energy = 255.9
+        energy = 1240
         ejecta_density = 100000.009
         iron_group_fraction = 0.5
         calculate_opacity.pair_creation_opacity_calculation(
diff --git a/benchmarks/transport_montecarlo_packet_trackers.py b/benchmarks/transport_montecarlo_packet_trackers.py
index e4207d23892..26739847bc6 100644
--- a/benchmarks/transport_montecarlo_packet_trackers.py
+++ b/benchmarks/transport_montecarlo_packet_trackers.py
@@ -9,7 +9,6 @@
 from tardis.transport.montecarlo import packet_trackers

-@parameterize({"num_packets": [10, 100], "length": [10, 50]})
 class BenchmarkTransportMontecarloPacketTrackers(BenchmarkBase):
     """
     Class to benchmark the numba R packet function.
@@ -18,21 +17,17 @@ class BenchmarkTransportMontecarloPacketTrackers(BenchmarkBase):
     repeat = 2

     @functools.cache
-    def setup(self, num_packets, length):
+    def setup(self):
         sim = self.simulation_rpacket_tracking_enabled
         self.TransportState = sim.transport.transport_state

-    def time_rpacket_trackers_to_dataframe(self, num_packets, length):
+    def time_rpacket_trackers_to_dataframe(self):
         packet_trackers.rpacket_trackers_to_dataframe(
             self.TransportState.rpacket_tracker
         )

-    def time_generate_rpacket_tracker_list(self, num_packets, length):
-        packet_trackers.generate_rpacket_tracker_list(num_packets, length)
+    def time_generate_rpacket_tracker_list(self):
+        packet_trackers.generate_rpacket_tracker_list(50, 10)

-    def time_generate_rpacket_last_interaction_tracker_list(
-        self, num_packets, length
-    ):
-        packet_trackers.generate_rpacket_last_interaction_tracker_list(
-            num_packets
-        )
+    def time_generate_rpacket_last_interaction_tracker_list(self):
+        packet_trackers.generate_rpacket_last_interaction_tracker_list(50)

No need to need benchmarks for different values of length for generate_rpacket_last_interaction_tracker_list.
@officialasishkumar Currently the said benchmark runs for different values of length, which is not required.
The related code:
https://github.com/tardis-sn/tardis/blob/e1aa88723a6836e8a25cc1afb24b578b1b78651f/benchmarks/transport_montecarlo_packet_trackers.py#L33-L38

My question, was this intended? if not can you please remove that since it can increase the runtime of benchmark workflow.

Yeah it can be removed and the repeat can be set to 3 or 4 for better accuracy.


diff --git a/cl/opinion_page/templates/docket_authorities.html b/cl/opinion_page/templates/docket_authorities.html
index 05e3154e38..7390f2ef36 100644
--- a/cl/opinion_page/templates/docket_authorities.html
+++ b/cl/opinion_page/templates/docket_authorities.html
@@ -13,12 +13,12 @@
 {% block tab-content %}

-    {% with authorities_count=docket.authority_count %}
+    {% with authorities_count=opinions_cited.count %}

The filings in this docket cite {{authorities_count|intcomma}} opinion{{authorities_count|pluralize}}

     {% endwith %}

     {# Westlaw's metadata organization: Checkbox, Treatment, Name + citation + summary, Date, Type, Depth #}
-    {% include 'includes/authorities_list.html' with authorities=authorities %}
+    {% include 'includes/docket_authorities_list.html' %}

 {% endblock %}
diff --git a/cl/opinion_page/templates/includes/docket_authorities_list.html b/cl/opinion_page/templates/includes/docket_authorities_list.html
new file mode 100644
index 0000000000..4ffdfeaeb1
--- /dev/null
+++ b/cl/opinion_page/templates/includes/docket_authorities_list.html
@@ -0,0 +1,18 @@
+{% load text_filters %}
+
+

+  {% for opinion in opinions_cited %}
+

+
+        {{ opinion.num_filings }} filing{{ opinion.num_filings|pluralize }}
+
+      make{% if opinion.num_filings == 1 %}s{% endif %} {{ opinion.total_depth }} reference{{ opinion.total_depth|pluralize }} to
+
+        {{ opinion.cluster.caption|safe|v_wrapper }}
+
+

+      {{ opinion.cluster.docket.court }}
+      {{ opinion.cluster.date_filed }}
+

+  {% endfor %}
+

diff --git a/cl/opinion_page/templates/includes/authorities_list.html b/cl/opinion_page/templates/includes/rd_authorities_list.html
similarity index 63%
rename from cl/opinion_page/templates/includes/authorities_list.html
rename to cl/opinion_page/templates/includes/rd_authorities_list.html
index ed9fa3a942..279cd34df2 100644
--- a/cl/opinion_page/templates/includes/authorities_list.html
+++ b/cl/opinion_page/templates/includes/rd_authorities_list.html
@@ -3,14 +3,7 @@

   {% for authority in authorities %}

-    {% if docket %}
-
-    {% endif %}
-      {{ authority.depth }} reference{{ authority.depth|pluralize }}
-    {% if docket %}
-
-    {% endif %}
-    to
+    {{ authority.depth }} reference{{ authority.depth|pluralize }} to

       {{ authority.cited_opinion.cluster.caption|safe|v_wrapper }}

diff --git a/cl/opinion_page/templates/recap_authorities.html b/cl/opinion_page/templates/recap_authorities.html
index 7a69d4b0fe..1980313837 100644
--- a/cl/opinion_page/templates/recap_authorities.html
+++ b/cl/opinion_page/templates/recap_authorities.html
@@ -13,7 +13,7 @@
This document cites {{ authorities_count|intcomma }} opinion{{ authorities_c
       {% endwith %}

       {# Westlaw's metadata organization: Checkbox, Treatment, Name + citation + summary, Date, Type, Depth #}
-      {% include 'includes/authorities_list.html' with authorities=authorities %}
+      {% include 'includes/rd_authorities_list.html' with authorities=authorities %}

 {% endblock %}
diff --git a/cl/opinion_page/views.py b/cl/opinion_page/views.py
index 32fee0691b..a2d062e530 100644
--- a/cl/opinion_page/views.py
+++ b/cl/opinion_page/views.py
@@ -543,7 +543,7 @@ async def docket_authorities(
             # Needed to show/hide parties tab.
             "parties": await docket.parties.aexists(),
             "docket_entries": await docket.docket_entries.aexists(),
-            "authorities": docket.authorities_with_data,
+            "opinions_cited": docket.authority_opinions.distinct(),
         }
     )
     return TemplateResponse(request, "docket_authorities.html", context)
diff --git a/cl/search/models.py b/cl/search/models.py
index 51084008e3..851d756e23 100644
--- a/cl/search/models.py
+++ b/cl/search/models.py
@@ -12,7 +12,8 @@
 from django.contrib.postgres.indexes import HashIndex
 from django.core.exceptions import ValidationError
 from django.db import IntegrityError, models, transaction
-from django.db.models import Q, QuerySet
+from django.db.models import Prefetch, Q, QuerySet
+from django.db.models.aggregates import Count, Sum
 from django.db.models.functions import MD5
 from django.urls import NoReverseMatch, reverse
 from django.utils import timezone
@@ -870,6 +871,22 @@ def authorities_with_data(self):
         """
         return build_authorities_query(self.authorities)

+    @property
+    def authority_opinions(self):
+        """Return a queryset of Opinions cited by this docket's RDs.
+
+        This is similar to the authorities property, but instead of
+        listing OpinionsCitedByRECAPDocuments, it lists Opinions.
+
+        The returned queryset is annotated with the number of filings
+        and the total depth across references, and optimized by prefetching
+        related data.
+        """
+        opinions = Opinion.objects.filter(
+            citing_documents__citing_document__docket_entry__docket_id=self.pk
+        )
+        return build_authority_opinions_query(opinions)
+
     def add_idb_source(self):
         if self.source in self.NON_IDB_SOURCES():
             self.source = self.source + self.IDB
@@ -3290,6 +3307,51 @@ class Meta:
         indexes = [models.Index(fields=["depth"])]

+def build_authority_opinions_query(
+    base_queryset: QuerySet[Opinion],
+) -> QuerySet[Opinion]:
+    """Annotates a queryset of Opinions and optimizes related queries.
+
+    The annotated queryset includes the number of filings (total number of
+    RECAPDocuments that cite each opinion) and the total depth (total
+    number of references across documents).
+    """
+    return (
+        base_queryset.select_related("cluster__docket__court")
+        .prefetch_related(
+            "cluster__citations",
+            "citing_documents",
+            Prefetch(
+                "cluster__sub_opinions",
+                queryset=Opinion.objects.only("pk", "cluster_id"),
+            ),
+        )
+        .annotate(
+            num_filings=Count(
+                "citing_documents__citing_document", distinct=True
+            ),
+            total_depth=Sum("citing_documents__depth"),
+        )
+        .only(
+            "cluster__id",
+            "cluster__blocked",
+            "cluster__case_name",
+            "cluster__case_name_full",
+            "cluster__case_name_short",
+            "cluster__docket_id",
+            "cluster__docket__court__full_name",
+            "cluster__docket__docket_number",
+            "cluster__docket__court_id",
+            "cluster__docket__court__citation_string",
+            "cluster__slug",
+            "cluster__citation_count",
+            "cluster__docket_id",
+            "cluster__date_filed",
+        )
+        .order_by("-num_filings", "-total_depth")
+    )
+
+
 def build_authorities_query(
     base_queryset: QuerySet[OpinionsCitedByRECAPDocument],
 ) -> QuerySet[OpinionsCitedByRECAPDocument]:

Clarify number of references on Authorities Page vs. Search Results
Noticed after doing #4134

On the “Authorities” page for a given docket, we display the references to opinions in the docket's documents. However, each "authority" in the list is not actually an opinion, but an element of a `Docket`'s `authorities`:

```python
    @property
    def authorities(self):
        """Returns a queryset that can be used for querying and caching
        authorities.
        """
        return OpinionsCitedByRECAPDocument.objects.filter(
            citing_document__docket_entry__docket_id=self.pk
        )
```

```python
class OpinionsCitedByRECAPDocument(models.Model):
    citing_document = models.ForeignKey(
        RECAPDocument, related_name="cited_opinions", on_delete=models.CASCADE
    )
    cited_opinion = models.ForeignKey(
        Opinion, related_name="citing_documents", on_delete=models.CASCADE
    )
    depth = models.IntegerField(
        help_text="The number of times the cited opinion was cited "
        "in the citing document",
        default=1,
    )
```

This means an opinion cited by multiple documents in the same docket will be listed multiple times, like the authorities in [this docket](https://www.courtlistener.com/docket/4214664/authorities/national-veterans-legal-services-program-v-united-states/). Notice `Norman v. United States, 429 F.3d 1081 (Fed. Cir. 2005)` being listed 14 times, where [all links point to the same search](https://www.courtlistener.com/?type=r&q=docket_id%3A4214664%20AND%20cites%3A(211579)) which yields 14 docket entries.
![Image](https://github.com/user-attachments/assets/99b998fa-4920-44a8-aa30-d66ada80e38c)
![Image](https://github.com/user-attachments/assets/1e355972-932a-48ba-851f-723fe5518215)

This also means the counts are confusing. Each "depth" represents only the number of times the opinion is cited in one citing document, not the total number of references across all documents in the docket. We could:
1. Make the counts less confusing by making the authorities list include the citing document and changing the text to something like `5 references to <CITED_OPINION> in <CITING_DOC>`, with a link to the citing doc.
2. Group authorities by opinion, then aggregate the depth. This way we would get the summary of all references to a given opinion in a given docket, instead of having the same opinion repeated several times. This does sound like it could be a lot more work, but potentially more informative if we could also include a sub-list of all citing docs with their links.

    So instead of:

    > - 5 references to Norman v. United States, 429 F.3d 1081 (Fed. Cir. 2005)
    > Court of Appeals for the Federal Circuit Nov. 18, 2005
    > - 5 references to Norman v. United States, 429 F.3d 1081 (Fed. Cir. 2005)
    > Court of Appeals for the Federal Circuit Nov. 18, 2005
    > - 2 references to Norman v. United States, 429 F.3d 1081 (Fed. Cir. 2005)
    > Court of Appeals for the Federal Circuit Nov. 18, 2005

    It would be:

    > - 12 references to Norman v. United States, 429 F.3d 1081 (Fed. Cir. 2005)
    > Court of Appeals for the Federal Circuit Nov. 18, 2005
    >   - 5 references in citing doc 1
    >   - 5 references in citing doc 2
    >   - 2 references in citing doc 3

3. Update the search results to display the depth of treatment when a cites query is made, so that each result shown says something like, "22 references to case XYZ".

Whatever the option, we should make sure that the opinions' authorities page doesn't get broken since `authorities_list.html` is used in both docket and opinion authorities, which are not `OpinionsCitedByRECAPDocument` but `OpinionsCited` so we don't always have the same attributes available.

Fun, so this issue is indeed worse than we thought. I didn't realize that we're repeating the same case many times in the list of authorities. That's not great.

The easiest thing is to aggregate on the depth and have it say something like:

> 3 filings make 14 references to XYZ

We don't need to say which filings do that on this page, so we can spare ourselves that nested layout you describe.

Aggregating the depth should be easy. I can't think offhand how to get the filing count, but I'm guessing it's not too hard either and can be done at the query-level as well.

If we do the above, I think that fixes the confusion issue too, since it says the number of documents and then when you click on it, that's how many show up in the search results.

One last thing: We're introducing a second count to this page. Currently, it's ordered by the citation depth, but I think if we make this change, we should order by the number of filings citing a document instead. I guess a later feature could be to allow users to choose which ordering they prefer.
lol sup, i'm gonna look at this soon
@tactipus, we're planning to fix this in our coming sprint, which starts Monday. Are you still thinking about helping with this one?
> [@tactipus](https://github.com/tactipus), we're planning to fix this in our coming sprint, which starts Monday. Are you still thinking about helping with this one?

yeah. just wanted to know if elisa still wanted to do paired programming so it can be scheduled. i'm looking at the Docket class rn
Up to you guys. I'll get out of the way. :) @elisa-a-v?
Oh I'd love to! @tactipus if you're down, let me know when would be a good time for you and I can probably adjust my schedule :)
@elisa-a-v I am good after 17:00 EST, usually. i can also do 13:00 to 16:00 EST
@tactipus that's great, 13:00 EST generally works for me as well, but I think we should probably move this discussion over to email so we don't keep cluttering up the issue thread 😅 my address is elisa@free.law—feel free to reach out!
Good morning,

This comment is more a record than anything. Per our conversation yesterday, we will focus on views.py & map out the implementation @mlissner discussed. That way, we can avoid tinkering too much with the models.py.

The solution is to map out the references using an array or a query set IIRC. It was all @elisa-a-v's idea, I just took notes ;p

```python
async def docket_authorities(
    request: HttpRequest,
    docket_id: int,
    slug: str,
) -> HttpResponse:
    docket, context = await core_docket_data(request, docket_id)
    if not await docket.ahas_authorities():
        raise Http404("No authorities data for this docket at this time")

    context.update(
        {
            # Needed to show/hide parties tab.
            "parties": await docket.parties.aexists(),
            "docket_entries": await docket.docket_entries.aexists(),
            "authorities": docket.authorities_with_data,

        }
    )
    return TemplateResponse(request, "docket_authorities.html", context)
```
That's correct, we basically need two things:
1. Map the `authorities` in the `context` of that view so that it's an iterable of `Opinion` instances instead of `OpinionsCitedByRECAPDocuments`, with the number of filings (how many `RECAPDocuments` cite this `Opinion` in this `Docket`) and the number of references (the aggregated `depth` of all those `OpinionsCitedByRECAPDocument` instances)
    - We now list `Opinions` instead of `OpinionsCitedByRECAPDocuments` so we only list each opinion once.
    - I believe the iterable could either be a `QuerySet` if you know your way around annotations, or simply a list. I imagine something like:
      ```python
      >>> context["authorities"]
      [
          {
              "opinion": Opinion1,
              "filings": OpinionsCitedByRECAPDocument.filter(cited_opinion=Opinion1, ...).count(),
              "references": OpinionsCitedByRECAPDocument.filter(cited_opinion=Opinion1, ...).aggregate(...)["sum"],
          },
          {
              "opinion": Opinion2,
              "filings": OpinionsCitedByRECAPDocument.filter(cited_opinion=Opinion2, ...).count(),
              "references": OpinionsCitedByRECAPDocument.filter(cited_opinion=Opinion2, ...).aggregate(...)["sum"],
          },
      ]
      ````
2. Update the template to display the info in the new format without breaking the document authorities view (they both use the same `authorities_list.html` template) which should probably remain unchanged:
![Image](https://github.com/user-attachments/assets/322739b5-eb8d-441e-9cc1-f2663c2c700d)

@mlissner I did notice a small thing when looking through the `authorities_list.html` template: in [line 14](https://github.com/freelawproject/courtlistener/blob/main/cl/opinion_page/templates/includes/authorities_list.html#L14) it says `{% if authority.blocked %}`, but I don't see how that's ever `True`. I wonder if maybe that's a mistake and it should be `authority.cited_opinion.cluster.blocked` instead?

```html
<a href="{{ authority.cited_opinion.cluster.get_absolute_url }}{% querystring %}" {% if authority.blocked %}rel="nofollow" {% endif %}>
```
I just emailed @elisa-a-v about this. To recap that email, I suggested that we use `annotate()` to get counts. `annotate()` returns QuerySet objects, which are iterable while `aggregate()` provides a dictionary, which is iterable but...not as flexible I believe.
> I wonder if maybe that's a mistake and it should be authority.cited_opinion.cluster.blocked instead?

That's certainly possible. The idea here is to tell crawlers not to waste their time crawling pages that we already know are blocked. Google and other crawlers give you a budget of links that they'll follow (called the "Crawl Budget"), so it's important not to send them to pages they can't index anyway.

I think you can get all the information you need from the queryset with something like this:

```
cited = (
    OpinionsCitedByRECAPDocument.objects
        .filter(citing_document__docket_entry__docket_id=4214664)
        .values("cited_opinion_id")
        .annotate(opinion_count=Count('cited_opinion_id'), total_depth=Sum('depth'))
)
```

That returns objects like:

```
{'cited_opinion_id': 9339585, 'opinion_count': 1, 'total_depth': 1}
```

And if you iterate over the entire thing you get results like:

```
In [27]: for c in cited:
    ...:     print(f"citation_count: {c["opinion_count"]}; total depth: {c["total_depth"]}")
    ...
    citation_count: 1; total depth: 1
    citation_count: 4; total depth: 35
    citation_count: 1; total depth: 1
    citation_count: 1; total depth: 1
    citation_count: 1; total depth: 2
    ...
```

Does this work as a solution (if you pick the fields carefully, do the prefetches, etc)?
> That's certainly possible. The idea here is to tell crawlers not to waste their time crawling pages that we already know are blocked. Google and other crawlers give you a budget of links that they'll follow (called the "Crawl Budget"), so it's important not to send them to pages they can't index anyway.

I understand, well from what I saw I don't think the `authority` actually has any `blocked` attribute so I'd bet it's not working, unless I'm missing something 🤔

> Does this work as a solution (if you pick the fields carefully, do the prefetches, etc)?

Yes that makes a lot of sense to me!


diff --git a/.gitignore b/.gitignore
index ec39dfd0..2f555a67 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,9 +21,10 @@ examples/strava-oauth/settings.cfg
 .nox
 .vscode/*
 docs/_build/*
+.env

-# so people don't mistakenly delete and submit a pr with these
+# So people don't mistakenly delete and submit a pr with these
 stravalib/tests/test.ini-example
 stravalib/tests/test.ini
 stravalib/docs/api
diff --git a/changelog.md b/changelog.md
index 3ddeba07..4eff92c5 100644
--- a/changelog.md
+++ b/changelog.md
@@ -5,7 +5,8 @@
 ### Added

 - Add: `SummaryAthlete` return to exchange_code_for_token (@lwasser, #552)
-- Add: Add more information about how our mock test fixture works (@lwasser, #292)
+- Add: Add more information about how our mock fixture works (@lwasser, #292)
+- Add(feature): Automatic token refresh :sparkles: built into stravalib (@lwasser, @jsamoocha, #585)

 ### Fixed

diff --git a/docs/contributing/development-guide.md b/docs/contributing/development-guide.md
index f60b6c8b..a9e6a441 100644
--- a/docs/contributing/development-guide.md
+++ b/docs/contributing/development-guide.md
@@ -219,7 +219,7 @@ force the branch to be in the same commit state as your local branch.

 ### Typing using mypy

-To ensure proper typing throughout our library we use [mypy](https://mypy.readthedocs.io/). To run `mypy` across Python versions use:
+We use [mypy](https://mypy.readthedocs.io/) to ensure proper typing throughout our library. To run `mypy` across Python versions, use:

 `nox -s mypy`

@@ -346,7 +346,6 @@ uploaded on the GitHub action run on `ubuntu` and `Python 3.11`. When that step
 actions completes, the report will be processed and returned to the pull request.
 ```

-
 ## Tests & the stravalib mock fixture

 To run integration tests that ensure stravalib is interacting with API data correctly, Stravalib uses a mock object accessed through a
diff --git a/pyproject.toml b/pyproject.toml
index 5e7d4690..5a6bbf3a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -62,7 +62,10 @@ build = ["build"]
 tests = [
   "pytest",
   "pytest-cov",
-  "responses"
+  # Run tests in parallel
+  "pytest-xdist",
+  "responses",
+  "pytest-mock"
 ]
 docs = [
   "sphinx",
diff --git a/src/stravalib/client.py b/src/stravalib/client.py
index 243b49d9..4c93ffd0 100644
--- a/src/stravalib/client.py
+++ b/src/stravalib/client.py
@@ -69,6 +69,8 @@ def __init__(
         rate_limit_requests: bool = True,
         rate_limiter: limiter.RateLimiter | None = None,
         requests_session: Session | None = None,
+        token_expires: int | None = None,
+        refresh_token: str | None = None,
     ) -> None:
         """
         Initialize a new client object.
@@ -86,6 +88,12 @@ def __init__(
             :class:`stravalib.util.limiter.DefaultRateLimiter` will be used.
         requests_session : requests.Session() object
             (Optional) pass request session object.
+        token_expires : int
+            epoch timestamp -- seconds since jan 1 1970 (Epoch timestamp)
+            This represents the date and time that the token will expire. It is
+            used to automatically check and refresh the token in the client
+            method on all API requests.
+        refresh_token : str

         """
         self.log = logging.getLogger(
@@ -103,6 +111,8 @@ def __init__(

         self.protocol = ApiV3(
             access_token=access_token,
+            refresh_token=refresh_token,
+            token_expires=token_expires,
             requests_session=requests_session,
             rate_limiter=rate_limiter,
         )
@@ -113,14 +123,13 @@ def access_token(self) -> str | None:
         return self.protocol.access_token

     @access_token.setter
-    def access_token(self, token_value: str) -> None:
+    def access_token(self, token_value: str | None) -> None:
         """Set the currently configured authorization token.

         Parameters
         ----------
-        token_value : int
-             User's access token for authentication.
-
+        access_token : str
+             User's access token

         Returns
         -------
@@ -128,6 +137,48 @@ def access_token(self, token_value: str) -> None:
         """
         self.protocol.access_token = token_value

+    @property
+    def token_expires(self) -> int | None:
+        """The currently configured authorization token."""
+        return self.protocol.token_expires
+
+    @token_expires.setter
+    def token_expires(self, expires_value: int) -> None:
+        """Used to set and update the refresh token.
+
+        Parameters
+        ----------
+        expires_value : int
+             Current token expiration time (epoch seconds)
+
+        Returns
+        -------
+
+        """
+        self.protocol.token_expires = expires_value
+
+    @property
+    def refresh_token(self) -> str | None:
+        """The currently configured authorization token."""
+        return self.protocol.refresh_token
+
+    @refresh_token.setter
+    def refresh_token(self, refresh_value: str) -> None:
+        """Used to set and update the refresh token.
+
+        Parameters
+        ----------
+        refresh_value : str
+             Current token refresh value.
+
+        Returns
+        -------
+        None
+            Updates the `refresh_token` attribute in the Client class.
+
+        """
+        self.protocol.refresh_token = refresh_value
+
     def authorization_url(
         self,
         client_id: int,
diff --git a/src/stravalib/protocol.py b/src/stravalib/protocol.py
index 25773496..b8efa313 100644
--- a/src/stravalib/protocol.py
+++ b/src/stravalib/protocol.py
@@ -8,6 +8,8 @@
 import abc
 import functools
 import logging
+import os
+import time
 from collections.abc import Callable
 from typing import TYPE_CHECKING, Any, Literal, TypedDict
 from urllib.parse import urlencode, urljoin, urlunsplit
@@ -64,6 +66,10 @@ def __init__(
         rate_limiter: (
             Callable[[dict[str, str], RequestMethod], None] | None
         ) = None,
+        token_expires: int | None = None,
+        refresh_token: str | None = None,
+        client_id: int | None = None,
+        client_secret: str | None = None,
     ):
         """Initialize this protocol client, optionally providing a (shared)
         :class:`requests.Session` object.
@@ -74,12 +80,27 @@ def __init__(
             The token that provides access to a specific Strava account.
         requests_session : :class:`requests.Session`
             An existing :class:`requests.Session` object to use.
-
+        rate_limiter : Callable[[dict[str, str], RequestMethod], None], optional
+            A callable used to enforce rate-limiting for API requests.
+            The callable accepts a dict of headers and the
+            HTTP request method as arguments. Defaults to None.
+        token_expires: int
+            Epoch time in seconds when the token expires
+        refresh_token: str
+            Refresh token used to re-authenticate with Strava
+        client_id: int
+            client id for the Strava APP pulled from the users envt
+        client_secret: str
+            client secret for the Strava app pulled from the users envt
         """
         self.log = logging.getLogger(
             "{0.__module__}.{0.__name__}".format(self.__class__)
         )
-        self.access_token = access_token
+        self.access_token: str | None = access_token
+        self.token_expires: int | None = token_expires
+        self.refresh_token: str | None = refresh_token
+        self.client_id: int | None = None
+        self.client_secret: str | None = None
         if requests_session:
             self.rsession: requests.Session = requests_session
         else:
@@ -88,6 +109,189 @@ def __init__(
         self.rate_limiter = rate_limiter or (
             lambda _request_params, _method: None
         )
+        # Check for credentials when initializing
+        self._check_credentials()
+
+    def _check_credentials(self) -> None:
+        """Gets Strava client_id and secret credentials from user's environment.
+
+        If the user environment is populated with both values and
+        client_id is a proper int, it returns a tuple with both values.
+        Otherwise it returns None.
+
+        Returns
+        -------
+        None
+            If the client_id and secret are available it populates self
+            with both variables to support automatic token refresh.
+        """
+        # Default both to None; set if they are available in the correct format
+        client_id: int | None = None
+        client_secret: str | None = None
+
+        client_id_str = os.environ.get("STRAVA_CLIENT_ID")
+        client_secret = os.environ.get("STRAVA_CLIENT_SECRET")
+        # Make sure client_id exists and can be cast to int
+        if client_id_str:
+            try:
+                # Make sure client_id is a valid int
+                client_id = int(client_id_str)
+            except ValueError:
+                logging.error("STRAVA_CLIENT_ID must be a valid integer.")
+        else:
+            logging.error(
+                "Please make sure your STRAVA_CLIENT_ID is set in your environment."
+            )
+
+        if client_id and client_secret:
+            self.client_id = client_id
+            self.client_secret = client_secret
+        else:
+            logging.warning(
+                "STRAVA_CLIENT_ID and STRAVA_CLIENT_SECRET not found in your "
+                " environment. Please refresh your access_token manually."
+                " Or add STRAVA_CLIENT_ID and STRAVA_CLIENT_SECRET to your environment."
+            )
+        return None
+
+    def _request(
+        self,
+        url: str,
+        params: dict[str, Any] | None = None,
+        files: dict[str, SupportsRead[str | bytes]] | None = None,
+        method: RequestMethod = "GET",
+        check_for_errors: bool = True,
+    ) -> Any:
+        """Perform the underlying request, returning the parsed JSON results.
+
+        Before running, this method will check to make sure that your
+        access_token is valid. If it isn't, it will refresh it and then make
+        the requested Strava API call.
+
+        Parameters
+        ----------
+        url : str
+            The request URL.
+        params : Dict[str,Any]
+            Request parameters
+        files : Dict[str,file]
+            Dictionary of file name to file-like objects.
+        method : str
+            The request method (GET/POST/etc.)
+        check_for_errors : bool
+            Whether to raise an error or not.
+
+        Returns
+        -------
+        Dict[str, Any]
+            The parsed JSON response.
+        """
+
+        # Only refresh token if we know the users' environment is setup
+        if "/oauth/token" not in url and self.client_id and self.client_secret:
+            self.refresh_expired_token()
+
+        url = self.resolve_url(url)
+        self.log.info(
+            "{method} {url!r} with params {params!r}".format(
+                method=method, url=url, params=params
+            )
+        )
+        if params is None:
+            params = {}
+        if self.access_token:
+            params["access_token"] = self.access_token
+
+        methods = {
+            "GET": self.rsession.get,
+            "POST": functools.partial(self.rsession.post, files=files),
+            "PUT": self.rsession.put,
+            "DELETE": self.rsession.delete,
+        }
+
+        try:
+            requester = methods[method.upper()]
+        except KeyError:
+            raise ValueError(
+                "Invalid/unsupported request method specified: {}".format(
+                    method
+                )
+            )
+
+        raw = requester(url, params=params)  # type: ignore[operator]
+        # Rate limits are taken from HTTP response headers
+        # https://developers.strava.com/docs/rate-limits/
+        self.rate_limiter(raw.headers, method)
+
+        if check_for_errors:
+            self._handle_protocol_error(raw)
+
+        # 204 = No content
+        if raw.status_code in [204]:
+            resp = {}
+        else:
+            resp = raw.json()
+
+        return resp
+
+    def _token_expired(self) -> bool:
+        """Checks if a token has expired or not. Returns True if it's expired.
+
+        Returns
+        -------
+        bool
+            True if token has expired, otherwise returns false
+        """
+        if self.token_expires:
+            if time.time() > self.token_expires:
+                print("Your token has expired; Refreshing it now.")
+                return True
+            else:
+                return False
+        else:
+            logging.warning(
+                "Please make sure you've set client.token_expires if you"
+                " want automatic token refresh to work"
+            )
+            return False
+
+    def refresh_expired_token(self) -> None:
+        """Checks to see if a token has expired and auto refreshes it
+        if the user has setup their environment with the client
+        secret information.
+
+        Returns
+        -------
+        None
+            If all is setup properly, updates and resets the access_token
+            attribute. Otherwise it logs a warning
+        """
+        if not self.refresh_token:
+            logging.warning(
+                "Please set client.refresh_token if you want to use"
+                "the auto token-refresh feature"
+            )
+            return
+
+        # Token is not yet expired, move on
+        if not self._token_expired():
+            return
+
+        # This should never be false, BUT mypy wants a reminder that these values
+        # are populated
+        assert self.client_id is not None, "client_id is required but is None."
+        assert (
+            self.client_secret is not None
+        ), "client_secret is required but is None."
+
+        # If the token is expired AND the refresh token exists
+        if self._token_expired() and self.refresh_token:
+            self.refresh_access_token(
+                client_id=self.client_id,
+                client_secret=self.client_secret,
+                refresh_token=self.refresh_token,
+            )
+            return

     def authorization_url(
         self,
@@ -217,7 +421,10 @@ def exchange_code_for_token(
             return access_info, None

     def refresh_access_token(
-        self, client_id: int, client_secret: str, refresh_token: str
+        self,
+        client_id: int,
+        client_secret: str,
+        refresh_token: str,
     ) -> AccessInfo:
         """Exchanges the previous refresh token for a short-lived access token
         and a new refresh token (used to obtain the next access token later on)
@@ -238,6 +445,12 @@ def refresh_access_token(
             Dictionary containing the access_token, refresh_token and
             expires_at (number of seconds since Epoch when the provided
             access token will expire)
+
+        Notes
+        -----
+        This method is user facing. Here, we don't populate client_id and
+        client_secret from self; A user can call this method and refresh the token manually with
+        those values.
         """
         response = self._request(
             f"https://{self.server}/oauth/token",
@@ -255,6 +468,9 @@ def refresh_access_token(
             "expires_at": response["expires_at"],
         }
         self.access_token = response["access_token"]
+        # Update expires_at and refresh to support automatic refresh
+        self.refresh_token = response["refresh_token"]
+        self.token_expires = response["expires_at"]

         return access_info

@@ -278,77 +494,6 @@ def resolve_url(self, url: str) -> str:
             )
         return url

-    def _request(
-        self,
-        url: str,
-        params: dict[str, Any] | None = None,
-        files: dict[str, SupportsRead[str | bytes]] | None = None,
-        method: RequestMethod = "GET",
-        check_for_errors: bool = True,
-    ) -> Any:
-        """Perform the underlying request, returning the parsed JSON results.
-
-        Parameters
-        ----------
-        url : str
-            The request URL.
-        params : Dict[str,Any]
-            Request parameters
-        files : Dict[str,file]
-            Dictionary of file name to file-like objects.
-        method : str
-            The request method (GET/POST/etc.)
-        check_for_errors : bool
-            Whether to raise
-
-        Returns
-        -------
-        Dict[str,Any]
-            The parsed JSON response.
-        """
-        url = self.resolve_url(url)
-        self.log.info(
-            "{method} {url!r} with params {params!r}".format(
-                method=method, url=url, params=params
-            )
-        )
-        if params is None:
-            params = {}
-        if self.access_token:
-            params["access_token"] = self.access_token
-
-        methods = {
-            "GET": self.rsession.get,
-            "POST": functools.partial(self.rsession.post, files=files),
-            "PUT": self.rsession.put,
-            "DELETE": self.rsession.delete,
-        }
-
-        try:
-            requester = methods[method.upper()]
-        except KeyError:
-            raise ValueError(
-                "Invalid/unsupported request method specified: {}".format(
-                    method
-                )
-            )
-
-        raw = requester(url, params=params)  # type: ignore[operator]
-        # Rate limits are taken from HTTP response headers
-        # https://developers.strava.com/docs/rate-limits/
-        self.rate_limiter(raw.headers, method)
-
-        if check_for_errors:
-            self._handle_protocol_error(raw)
-
-        # 204 = No content
-        if raw.status_code in [204]:
-            resp = {}
-        else:
-            resp = raw.json()
-
-        return resp
-
     def _handle_protocol_error(
         self, response: requests.Response
     ) -> requests.Response:

diff --git a/src/stravalib/tests/conftest.py b/src/stravalib/tests/conftest.py
index 063907b6..feb738a1 100644
--- a/src/stravalib/tests/conftest.py
+++ b/src/stravalib/tests/conftest.py
@@ -1,8 +1,11 @@
+import os
 import warnings
+from unittest.mock import patch

 import pytest

 from stravalib import Client
+from stravalib.protocol import ApiV3
 from stravalib.tests.integration.strava_api_stub import StravaAPIMock

 warnings.simplefilter("always")
@@ -18,3 +21,27 @@ def mock_strava_api():
 @pytest.fixture
 def client():
     return Client()
+
+
+@pytest.fixture
+def mock_strava_env():
+    """Fixture that mocks Strava environment variables."""
+    with patch.dict(
+        os.environ,
+        {"STRAVA_CLIENT_ID": "12345", "STRAVA_CLIENT_SECRET": "123ghp234"},
+        clear=True,  # Ensures other environment variables are not leaked
+    ):
+        yield
+
+
+@pytest.fixture
+def apiv3_instance(mock_strava_env):
+    """Fixture to create an ApiV3 instance for testing. Takes the
+    mock_strava_env which sets up environment variables"""
+    mock_strava_env
+    return ApiV3(
+        access_token="dummy_access_token",
+        client_id=123456,
+        client_secret="clientfoosecret",
+        refresh_token="refresh_value123",
+    )
diff --git a/src/stravalib/tests/unit/test_client.py b/src/stravalib/tests/unit/test_client.py
new file mode 100644
index 00000000..36a59869
--- /dev/null
+++ b/src/stravalib/tests/unit/test_client.py
@@ -0,0 +1,14 @@
+import stravalib
+
+
+def test_client_protocol_initialization():
+    """Test that instantiating Client sets corresponding values in protocol."""
+    client = stravalib.Client(
+        access_token="access_123",
+        token_expires=123456,
+        refresh_token="refresh_123",
+    )
+
+    assert client.protocol.access_token == "access_123"
+    assert client.protocol.token_expires == 123456
+    assert client.protocol.refresh_token == "refresh_123"
diff --git a/src/stravalib/tests/unit/test_protocol_ApiV3_token_refresh.py b/src/stravalib/tests/unit/test_protocol_ApiV3_token_refresh.py
new file mode 100644
index 00000000..820770c2
--- /dev/null
+++ b/src/stravalib/tests/unit/test_protocol_ApiV3_token_refresh.py
@@ -0,0 +1,191 @@
+import logging
+import os
+import time
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+import stravalib
+
+###### _request method responsible for API call ######
+
+
+@pytest.mark.parametrize(
+    "url, should_call_refresh",
+    [
+        ("/oauth/token", False),  # Skips refreshing for /oauth/token
+        (
+            "/api/some_other_endpoint",
+            True,
+        ),
+    ],
+)
+def test_request_skips_refresh_for_oauth_token(
+    apiv3_instance, url, should_call_refresh
+):
+    """Test that _request skips refresh_expired_token when provided with
+    a url that includes /oauth/token to avoid recursion"""
+
+    apiv3_instance.refresh_expired_token = MagicMock()
+    apiv3_instance.resolve_url = MagicMock(return_value="/oauth/token")
+    apiv3_instance.rsession.get = MagicMock(
+        return_value=MagicMock(status_code=204, headers={})
+    )
+
+    apiv3_instance._request(url)
+
+    if should_call_refresh:
+        apiv3_instance.refresh_expired_token.assert_called_once()
+    else:
+        apiv3_instance.refresh_expired_token.assert_not_called()
+
+
+###### _check_credentials helper ######
+
+
+class TestCheckCredentials:
+    """Tests for the `_check_credentials` app setup helper method."""
+
+    @pytest.fixture(autouse=True)
+    def setup(self, apiv3_instance):
+        """Shared setup for all tests in this class."""
+        self.apiv3 = apiv3_instance
+
+    def test_check_credentials_success(self):
+        """Ensure that if credentials exist in the environment and client_id can
+        be cast to a valid int that the method populates self. client ID and
+        secret as expected."""
+
+        self.apiv3._check_credentials()
+        assert isinstance(self.apiv3.client_id, int)
+
+    @pytest.mark.parametrize(
+        "env_vars, expected_log_message",
+        [
+            (
+                {
+                    "STRAVA_CLIENT_ID": "not_an_int",
+                    "STRAVA_CLIENT_SECRET": "123ghp234",
+                },
+                "STRAVA_CLIENT_ID must be a valid integer.",
+            ),  # Invalid client ID
+            (
+                {"STRAVA_CLIENT_SECRET": "123ghp234"},
+                "Please make sure your STRAVA_CLIENT_ID is set",
+            ),  # Missing client ID
+            (
+                {"STRAVA_CLIENT_ID": "12345"},
+                "STRAVA_CLIENT_ID and STRAVA_CLIENT_SECRET not found",
+            ),  # Missing secret
+        ],
+    )
+    def test_check_credentials_missing_vals(
+        self, caplog, env_vars, expected_log_message
+    ):
+        """Test that if parts of all of the credentials are missing, the method
+        logs the correct warning and returns `None`.
+
+        Here we overwriting environment variables to see if _check_credentials
+        works as expected with various user envt edge cases."""
+
+        with patch.dict(os.environ, env_vars, clear=True):
+            with caplog.at_level(logging.WARNING):
+                credentials = self.apiv3._check_credentials()
+                assert expected_log_message in caplog.text
+                assert credentials is None
+
+
+###### refresh_expired_token method ######
+
+
+@patch.object(stravalib.protocol.ApiV3, "refresh_access_token")
+@patch.object(stravalib.protocol.ApiV3, "_token_expired", return_value=True)
+def test_refresh_expired_token_calls_refresh_access_token(
+    token_expired_mock,
+    refresh_access_token_mock,
+    apiv3_instance,
+):
+    """Test that `refresh_access_token` is called when the token is expired and
+    the refresh token exists.
+
+    This tests mocks refresh_access_token so it doesn't run, but checks to
+    make sure it's called as it should be if all values are present.
+    """
+
+    out = apiv3_instance.refresh_expired_token()
+    refresh_access_token_mock.assert_called_once()
+    # Should return none
+    assert not out
+
+
+###### _token_expired helper ######
+
+
+@pytest.mark.parametrize(
+    "token_expires, expected_result",
+    [
+        (time.time() - 10, True),  # Token expired
+        (time.time() + 10, False),
+    ],
+)
+def test_token_expired(apiv3_instance, token_expires, expected_result):
+    """Test the _token_expired method for both expired and valid tokens."""
+
+    apiv3_instance.token_expires = token_expires
+    assert apiv3_instance._token_expired() == expected_result
+
+
+# caplog is a builtin fixture in pytest that captures logging outputs
+def test_token_expires_is_none_logs_warning(apiv3_instance, caplog):
+    """Test that _token_expired logs a warning if token_expires is None."""
+    apiv3_instance.token_expires = None
+
+    with caplog.at_level(logging.WARNING):
+        assert not apiv3_instance._token_expired()
+        assert (
+            "Please make sure you've set client.token_expires" in caplog.text
+        )
+
+
+class TestRefreshExpiredToken:
+    """Tests for the `refresh_expired_token` method."""
+
+    @pytest.fixture(autouse=True)
+    def setup(self, apiv3_instance):
+        """
+        Shared setup for all tests in this class.
+        Mocks `_token_expired` and `refresh_access_token` and sets up
+        the environment variables.
+        """
+        self.apiv3 = apiv3_instance
+        self.apiv3._token_expired = MagicMock()
+        self.apiv3.refresh_access_token = MagicMock()
+
+    @pytest.mark.parametrize(
+        "token_expired, expected_call", [(True, 1), (False, 0)]
+    )
+    def test_refresh_expired_token_refresh_called(
+        self, token_expired, expected_call
+    ):
+        """Test that `refresh_access_token` method is called when token is expired
+        and not called when valid.
+        """
+        self.apiv3._token_expired.return_value = token_expired
+        self.apiv3.refresh_expired_token()
+
+        assert self.apiv3.refresh_access_token.call_count == expected_call
+
+    def test_refresh_expired_token_no_refresh(self, caplog):
+        """Test that we fail gracefully and log an error if the refresh
+        token isn't populated.
+        """
+
+        self.apiv3.refresh_token = None
+        self.apiv3._token_expired.return_value = True
+
+        with caplog.at_level(logging.WARNING):
+            self.apiv3.refresh_expired_token()
+            assert (
+                "Please set client.refresh_token if you want to use"
+                in caplog.text
+            )

ENH: built-in token refresh
### Feature Type

- [ ] Adding new functionality to stravalib

- [X] Changing existing functionality in stravalib

- [ ] Removing existing functionality in stravalib

### Problem Description

It would be nice if stravalib would automatically refresh access tokens whenever they expire.

### Feature Description

Calling a client method will automatically refresh an access token if it's expired (or about to expire).

### Alternative Solutions

Letting users handle token expiry themselves (which is the current situation)

### Additional Context

_No response_

### Code of Conduct

- [X] I agree to follow this project's Code of Conduct

@jsamoocha i'd really love to work on this issue. I haven't created a decorator before but use them and want to better understand how they work. I was thinking I would make a decorator that runs on the [`protocol.ApiV3._request` helper method](https://github.com/stravalib/stravalib/blob/main/src/stravalib/protocol.py#L267)

I will start working on this, but please know that you can totally redirect, refactor, and redo any PRs that I submit! For me, this will be a really cool learning project.
Okay, before I go any further, I want to think through handling the client_secret and ID. I'm worried about making that an attribute of our ApiV3 and client objects, so instead, I thought about a three-pronged approach.

1. Use a . file - python-dotenv will allow a user to create a .env file with both values, and access them when it tries to refresh. This will work well for users working locally who don't want to deal with environment variables.
2. If python-dotenv is not installed, check the user's environment for client_secret and client_id. This will work well in any CI build and local system with users who can add environment variables.
3. the user must refresh their token if the above attempts fail. We raise a message that says, "Your token has expired; please refresh it."

Is there a better way or is this a sound approach?
If you modify `_request()`, there is no need for a decorator. But it may introduce some issues with recursive calls (i.e., `_request()` may need to call `refresh_access_token()`, which in turn calls `_request()`). You may need to build guards against potentially infinite recursion.

Your approach to dealing with the `client_secret` seems good, but please be aware I'm not the no1 security expert.


diff --git a/pyproject.toml b/pyproject.toml
index 5746b069a..60bac8dd7 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -47,7 +47,6 @@ dependencies = [
     "xmltodict>=0.12,<0.14",
     "pytz",
     "requests>=2.24.0,<3.0.0",
-    "dnspython<3.0",
     "sqlalchemy>=1.4,<1.5",
     "importlib_metadata>=3.6",
     "packaging>=23.2",
diff --git a/sopel/builtins/url.py b/sopel/builtins/url.py
index fc121b8c4..c9c1fcb30 100644
--- a/sopel/builtins/url.py
+++ b/sopel/builtins/url.py
@@ -10,22 +10,24 @@
 """
 from __future__ import annotations

+from email.message import EmailMessage
 from ipaddress import ip_address
 import logging
 import re
+from socket import getaddrinfo, IPPROTO_TCP
 from typing import NamedTuple, Optional, TYPE_CHECKING
 from urllib.parse import urlparse

-import dns.resolver
 import requests
 from urllib3.exceptions import LocationValueError  # type: ignore[import]

 from sopel import plugin, privileges, tools
 from sopel.config import types
-from sopel.tools import web

 if TYPE_CHECKING:
     from collections.abc import Generator
+    from typing import Iterable
+
     from sopel.bot import Sopel, SopelWrapper
     from sopel.config import Config
     from sopel.trigger import Trigger
@@ -73,7 +75,14 @@ class UrlSection(types.StaticSection):
     """If greater than 0, the title fetcher will include a TinyURL version of links longer than this many characters."""
     enable_private_resolution = types.BooleanAttribute(
         'enable_private_resolution', default=False)
-    """Enable requests to private and local network IP addresses"""
+    """Allow all requests to private and loopback networks.
+
+    If disabled (the default), obvious attempts to load pages from loopback and
+    private IP addresses will be blocked. If this matters for your security you
+    must use additional protections like a firewall and CSRF tokens, since an
+    attacker can change which IP address a domain name refers to between when
+    Sopel checks it and when the HTTP request is made.
+    """

 def configure(config: Config):
@@ -84,7 +93,7 @@ def configure(config: Config):
     | exclude | https?://git\\\\.io/.* | A list of regular expressions for URLs for which the title should not be shown. |
     | exclusion\\_char | ! | A character (or string) which, when immediately preceding a URL, will stop the URL's title from being shown. |
     | shorten\\_url\\_length | 72 | If greater than 0, the title fetcher will include a TinyURL version of links longer than this many characters. |
-    | enable\\_private\\_resolution | False | Enable requests to private and local network IP addresses. |
+    | enable\\_private\\_resolution | False | Allow all requests to private IP addresses. Leaving this disabled only blocks obvious attempts, use a firewall! |
     """
     config.define_section('url', UrlSection)
     config.url.configure_setting(
@@ -107,7 +116,7 @@ def configure(config: Config):
     )
     config.url.configure_setting(
         'enable_private_resolution',
-        'Enable requests to private and local network IP addresses?'
+        'Allow all requests to private (local network) IP addresses?'
     )

@@ -288,7 +297,7 @@ def title_command(bot: SopelWrapper, trigger: Trigger):
         urls = [bot.memory["last_seen_url"][trigger.sender]]
     else:
         # needs to be a list so len() can be checked later
-        urls = list(web.search_urls(trigger))
+        urls = list(tools.web.search_urls(trigger))

     for url, title, domain, tinyurl, ignored in process_urls(
         bot, trigger, urls, requested=True
@@ -334,21 +343,9 @@ def title_auto(bot: SopelWrapper, trigger: Trigger):
     if re.match(bot.config.core.prefix + r'\S+', trigger):
         return

-    unchecked_urls = web.search_urls(
+    urls = tools.web.search_urls(
         trigger, exclusion_char=bot.config.url.exclusion_char, clean=True)

-    urls = []
-    safety_cache = bot.memory.get("safety_cache", {})
-    safety_cache_local = bot.memory.get("safety_cache_local", {})
-    for url in unchecked_urls:
-        # Avoid fetching known malicious links
-        if url in safety_cache and safety_cache[url]["positives"] > 0:
-            continue
-        parsed = urlparse(url)
-        if not parsed.hostname or parsed.hostname.lower() in safety_cache_local:
-            continue
-        urls.append(url)
-
     for url, title, domain, tinyurl, ignored in process_urls(bot, trigger, urls):
         if not ignored:
             message = '%s | %s' % (title, domain)
@@ -418,60 +415,35 @@ def process_urls(
         if not requested and url.startswith(bot.config.url.exclusion_char):
             continue

-        parsed_url = urlparse(url)
-
         if check_callbacks(bot, url, use_excludes=not requested):
             # URL matches a callback OR is excluded, ignore
             yield URLInfo(url, None, None, None, True)
             continue

-        # Prevent private addresses from being queried if enable_private_resolution is False
-        # FIXME: This does nothing when an attacker knows how to host a 302
-        # FIXME: This whole concept has a TOCTOU issue
-        if not bot.config.url.enable_private_resolution:
-            if not parsed_url.hostname:
-                # URL like file:///path is a valid local path (i.e. private)
-                LOGGER.debug("Ignoring private URL: %s", url)
-                continue
-
-            try:
-                ips = [ip_address(parsed_url.hostname)]
-            except ValueError:
-                # Extra try/except here in case the DNS resolution fails, see #2348
-                try:
-                    ips = [ip_address(ip) for ip in dns.resolver.resolve(parsed_url.hostname)]
-                except Exception as exc:
-                    LOGGER.debug(
-                        "Cannot resolve hostname %s, ignoring URL %s"
-                        " (exception was: %r)",
-                        parsed_url.hostname,
-                        url,
-                        exc,
-                    )
-                    continue
-
-            private = False
-            for ip in ips:
-                if ip.is_private or ip.is_loopback:
-                    private = True
-                    break
-            if private:
-                LOGGER.debug("Ignoring private URL: %s", url)
-                continue
-
         # Call the URL to get a title, if possible
-        title = find_title(url)
-        if not title:
+        unsafe_urls = [
+            url
+            for url, data in bot.memory.get("safety_cache", {}).items()
+            if data.get("positives")
+        ]
+        title_results = find_title(
+            url,
+            allow_local=bot.config.url.enable_private_resolution,
+            unsafe_urls=unsafe_urls,
+            unsafe_domains=bot.memory.get("safety_cache_local", set()),
+        )
+        if not title_results:
             # No title found: don't handle this URL
             LOGGER.debug('No title found; ignoring URL: %s', url)
             continue
+        title, final_hostname = title_results

         # If the URL is over bot.config.url.shorten_url_length, shorten the URL
         tinyurl = None
         if (shorten_url_length > 0) and (len(url) > shorten_url_length):
             tinyurl = get_or_create_shorturl(bot, url)

-        yield URLInfo(url, title, parsed_url.hostname, tinyurl, False)
+        yield URLInfo(url, title, final_hostname, tinyurl, False)

 def check_callbacks(bot: SopelWrapper, url: str, use_excludes: bool = True) -> bool:
@@ -509,32 +481,107 @@ def check_callbacks(bot: SopelWrapper, url: str, use_excludes: bool = True) -> b
     )

-def find_title(url: str, verify: bool = True) -> Optional[str]:
-    """Return the title for the given URL.
+def find_title(
+    url: str,
+    verify: bool = True,
+    allow_local: bool = False,
+    unsafe_urls: Iterable[str] = [],
+    unsafe_domains: Iterable[str] = [],
+) -> Optional[tuple[str, str]]:
+    """Fetch the title for the given URL.

     :param verify: Whether to require a valid certificate when using https
+    :param allow_local: Allow requests to non-global addresses (RFC1918, etc.)
+    :param unsafe_urls: An iterable of URLs to consider malicious and ignore
+    :param unsafe_domains: An iterable of domains to consider malicious and ignore
+    :return: A tuple of the (title, final_hostname) that were found, or None
     """
-    try:
-        response = requests.get(url, stream=True, verify=verify,
-                                headers=DEFAULT_HEADERS)
-        raw_content = b''
-        for byte in response.iter_content(chunk_size=512):
-            raw_content += byte
-            if b'</title>' in raw_content or len(raw_content) > MAX_BYTES:
-                break
-        content = raw_content.decode('utf-8', errors='ignore')
-        # Need to close the connection because we have not read all
-        # the data
-        response.close()
-    except requests.exceptions.ConnectionError as e:
-        LOGGER.debug("Unable to reach URL: %r: %s", url, e)
-        return None
-    except (
-        requests.exceptions.InvalidURL,  # e.g. http:///
-        UnicodeError,  # e.g. http://.example.com (urllib3<1.26)
-        LocationValueError,  # e.g. http://.example.com (urllib3>=1.26)
-    ):
-        LOGGER.debug('Invalid URL: %s', url)
+    original_url = url
+    redirects_left = 5
+    session = requests.Session()
+    session.headers = dict(DEFAULT_HEADERS)
+    while redirects_left > 0:
+        redirects_left -= 1
+        parsed_url = urlparse(url)
+        if not parsed_url.hostname:
+            return None
+
+        # Avoid fetching known malicious links
+        if url in unsafe_urls:
+            LOGGER.debug("Ignoring unsafe URL: %r", url)
+            return None
+        if parsed_url.hostname.lower() in unsafe_domains:
+            LOGGER.debug("Ignoring unsafe domain: %r", url)
+            return None
+
+        # Prevent private addresses from being queried
+        try:
+            # If link is to an IP
+            ips = [ip_address(parsed_url.hostname)]
+        except ValueError:  # Nope, hostname
+            try:
+                # getaddrinfo instead of dns.resolver so we use normal OS
+                # name resolution, including hosts files.
+                addr_info = getaddrinfo(parsed_url.hostname, 443, proto=IPPROTO_TCP)
+                ips = [ip_address(info[4][0]) for info in addr_info]
+            except Exception as e:
+                LOGGER.debug("Failed to get IPs for %r: %s", url, e)
+                return None
+
+        # is_global excludes RFC1918, loopback, link-local, and v6 equivalents
+        if not allow_local and not all(ip.is_global for ip in ips):
+            LOGGER.debug(
+                "Ignoring private URL %r%s which resolved to %s",
+                url,
+                "" if url == original_url else " (redirected from %r)" % original_url,
+                ", ".join([str(ip) for ip in ips]),
+            )
+            return None
+
+        try:
+            response = session.get(
+                url,
+                stream=True,
+                verify=verify,
+                allow_redirects=False,
+            )
+            if response.is_redirect:
+                LOGGER.debug(
+                    "URL %r redirected to %r", url, response.headers.get("Location")
+                )
+                if "Location" not in response.headers:
+                    return None
+                url = response.headers["Location"]
+                continue
+
+            content_bytes = b''
+            for chunk in response.iter_content(chunk_size=512):
+                content_bytes += chunk
+                if b"</title>" in content_bytes or len(content_bytes) > MAX_BYTES:
+                    break
+
+            encoding = None
+            if "Content-Type" in response.headers:
+                msg = EmailMessage()
+                msg["Content-Type"] = response.headers["Content-Type"]
+                encoding = msg.get_content_charset()
+            content = content_bytes.decode(encoding or "utf-8", errors="ignore")
+
+            # Need to close the connection because we haven't read all the data
+            response.close()
+        except requests.exceptions.ConnectionError as e:
+            LOGGER.debug("Unable to reach URL: %r: %s", url, e)
+            return None
+        except (
+            requests.exceptions.InvalidURL,  # e.g. http:///
+            UnicodeError,  # e.g. http://.example.com (urllib3<1.26)
+            LocationValueError,  # e.g. http://.example.com (urllib3>=1.26)
+        ):
+            LOGGER.debug('Invalid URL: %s', url)
+            return None
+        break
+    else:
+        LOGGER.debug("Redirects exhausted for %r", original_url)
         return None

     # Some cleanup that I don't really grok, but was in the original, so
@@ -547,12 +594,12 @@ def find_title(url: str, verify: bool = True) -> Optional[str]:
     if start == -1 or end == -1:
         return None

-    title = web.decode(content[start + 7:end])
+    title = tools.web.decode(content[start + 7:end])
     title = title.strip()[:200]

     title = ' '.join(title.split())  # cleanly remove multiple spaces

-    return title or None
+    return (title, parsed_url.hostname)

 def get_or_create_shorturl(bot: SopelWrapper, url: str) -> Optional[str]:
@@ -579,7 +626,7 @@ def get_or_create_shorturl(bot: SopelWrapper, url: str) -> Optional[str]:
 def get_tinyurl(url: str) -> Optional[str]:
     """Returns a shortened tinyURL link of the URL"""
     base_url = "https://tinyurl.com/api-create.php"
-    tinyurl = "%s?%s" % (base_url, web.urlencode({'url': url}))
+    tinyurl = "%s?%s" % (base_url, tools.web.urlencode({'url': url}))
     try:
         res = requests.get(tinyurl)
         res.raise_for_status()

diff --git a/test/builtins/test_builtins_url.py b/test/builtins/test_builtins_url.py
index 54db06f24..eedce5411 100644
--- a/test/builtins/test_builtins_url.py
+++ b/test/builtins/test_builtins_url.py
@@ -22,6 +22,12 @@
     "http://example..com/",  # empty label
     "http://?",  # no host
 )
+PRIVATE_URLS = (
+    # "https://httpbin.org/redirect-to?url=http://127.0.0.1/",  # online
+    "http://127.1.1.1/",
+    "http://10.1.1.1/",
+    "http://169.254.1.1/",
+)

 @pytest.fixture
@@ -76,6 +82,11 @@ def test_find_title_invalid(site):
     assert url.find_title(site) is None

+@pytest.mark.parametrize("site", PRIVATE_URLS)
+def test_find_title_private(site):
+    assert url.find_title(site) is None
+
+
 def test_check_callbacks(mockbot):
     """Test that check_callbacks works with both new & legacy URL callbacks."""
     assert url.check_callbacks(mockbot, 'https://example.com/test')

url: private_resolution/dns_resolution useless
### Description
The url.enable_private_resolution and url.enable_dns_resolution settings do not work as advertised, and the concept of the latter is fatally flawed.

The current `url.py` private-address protection logic is as follows:
```python
if not enable_private_resolution:
    if host is ip and ip is private:
        return E_NAUGHTY
    if enable_dns_resolution and resolve(host) is private:
        return E_NAUGHTY
post_title(url)
```
This has many problems, as demonstrated below.

### Reproduction steps
Oh lordy.

#### Scenario 1: Attacker has $2
1. Buy a domain, e.g. `haha.bad`
2. Create a DNS record: `haha.bad A 127.0.0.1`
3. Post a link in IRC: `http://haha.bad/csrf/me/daddy`

If enable_dns_resolution is disabled, this bypasses all private-address protection.

#### Scenario 2: Attacker doesn't have $2
1. Configure webserver to respond to requests with `302 http://127.0.0.1/csrf/me/daddy`
2. Post a link to `http://$webserver_ip/`

The posted link is a public IP, so Sopel will happily load it and follow the redirect to the private address.

#### Scenario 3: Attacker has $2, but the above is fixed
1. Buy a domain, e.g. `haha.bad`
2. Set the nameservers for `haha.bad` to an IP you control
3. Run a script on that IP with the following behavior:
```
attack = False
if attack := not attack:
    return dns_record(type="A", address="1.2.3.4", ttl=0)
return dns_record(type="A", address="127.0.0.1", ttl=0)
```
In the checking stage, Sopel will see the address 1.2.3.4. When performing the request, 127.0.0.1 will be used without validation. ([TOCTOU](https://en.wikipedia.org/wiki/Time-of-check_to_time-of-use))

#### Scenario 4: Attacker has $2 and target is IPv6
1. Buy a domain, e.g. `haha.bad`
2. Point `haha.bad` to the target IPv6 address
3. Post a link to `http://haha.bad/csrf/me/daddy`

`dns.resolver.resolve()` does not request AAAA records, so any combination of DNS and IPv6 passes validation.

### Expected behavior
As explained on IRC, this behavior (especially the first two parts) is objectively broken and gives a false sense of security. We should either remove those config options and not pretend, or we should make url.py _unable_ to talk to private IPs.

### Environment
- Sopel `.version`: Since before the name "Sopel" to present.
- Relevant plugins: url.py

### Notes
Ping @dgw and @Exirel, who requested this be an issue instead of IRC comments and a PR.

url: Unexpected error on nonexistent domains
The following code line raises an unhandled `dns.resolver.NXDOMAIN` for URLs at nonexistent domains:

https://github.com/sopel-irc/sopel/blob/eff60e9b49a14e1f3c0c4dab5bb4d2ddbf0098a5/sopel/modules/url.py#L377

Some variant of this operation has been in the code for a while, but the context has changed so much during 8.x that I decided not to follow `git blame` all the way back to the first addition of `dns.resolver`. Seemed like a waste of time.