|
| 1 | +# Implementation Steps: Page Fetcher |
| 2 | + |
| 3 | +## Step 1: `FetchResult` |
| 4 | + |
| 5 | +- [x] Record `FetchResult(Status, html, etag, lastModified, httpStatus)` with nested `Status {OK, NOT_MODIFIED, NOT_FOUND, ERROR}` and small factory helpers |
| 6 | + |
| 7 | +**Related behaviors:** all (return shape) |
| 8 | + |
| 9 | +--- |
| 10 | + |
| 11 | +## Step 2: `HostRateLimiter` |
| 12 | + |
| 13 | +- [x] Per-host request spacing with injectable clock (`LongSupplier`) and sleeper (`LongConsumer`) for deterministic tests |
| 14 | +- [x] Independent buckets per host; non-positive rate disables throttling |
| 15 | + |
| 16 | +**Related behaviors:** Requests to the same host are throttled; Different hosts are not cross-throttled |
| 17 | + |
| 18 | +--- |
| 19 | + |
| 20 | +## Step 3: `PageFetcher` |
| 21 | + |
| 22 | +- [x] `@Component`; per-request bot `User-Agent`; conditional `If-None-Match`/`If-Modified-Since` headers |
| 23 | +- [x] `.exchange(...)` callback classifies status without throwing: 304 → NOT_MODIFIED, 404/410 → NOT_FOUND, 2xx → OK, else ERROR |
| 24 | +- [x] Body read under `max-body-bytes` cap via `readNBytes` (bounded memory); oversized → aborted ERROR with a logged warning |
| 25 | +- [x] Retry transient failures (5xx, network/timeout) with exponential backoff (injectable sleeper); 404 and other 4xx not retried |
| 26 | +- [x] Per-host rate limiting via `HostRateLimiter` |
| 27 | + |
| 28 | +**Related behaviors:** 200 body+validators; bot UA; 304; oversized; timeout transient; 5xx retried then ERROR; 404 no retry; recovery |
| 29 | + |
| 30 | +--- |
| 31 | + |
| 32 | +## Step 4: Wiring |
| 33 | + |
| 34 | +- [x] `ContentHttpConfig` provides the timeout-configured `contentRestClient` and the production `HostRateLimiter` (kept separate from `ContentConfig` so non-HTTP config stays isolated) |
| 35 | + |
| 36 | +**Related behaviors:** n/a (wiring) |
| 37 | + |
| 38 | +--- |
| 39 | + |
| 40 | +## Step 5: Tests |
| 41 | + |
| 42 | +- [x] `PageFetcherTest` via `MockRestServiceServer` (no network): 200, UA header, conditional/304, oversized, 404 no-retry, persistent 5xx, transient network error, 503→200 recovery — with a no-op rate limiter and recorded backoffs |
| 43 | +- [x] `HostRateLimiterTest` with a virtual clock + recording sleeper: same-host spacing, per-host independence, disabled rate |
| 44 | + |
| 45 | +**Acceptance criteria:** |
| 46 | +- [x] All tests pass (`mvn test`); build green |
| 47 | + |
| 48 | +--- |
| 49 | + |
| 50 | +## Behavior Coverage |
| 51 | + |
| 52 | +| Scenario | Layer | Covered in Step | |
| 53 | +|----------|-------|-----------------| |
| 54 | +| 200 returns body and validators | Backend | Step 5 (`PageFetcherTest.success200ReturnsBodyAndValidators`) | |
| 55 | +| Bot user-agent is sent | Backend | Step 5 (`PageFetcherTest.botUserAgentIsSent`) | |
| 56 | +| Unchanged page returns 304 | Backend | Step 5 (`PageFetcherTest.conditionalRequestYieldsNotModified`) | |
| 57 | +| Oversized body is capped | Backend | Step 5 (`PageFetcherTest.oversizedBodyIsAborted`) | |
| 58 | +| Request times out | Backend | Step 5 (`PageFetcherTest.transientNetworkErrorIsRetried`) | |
| 59 | +| Requests to the same host are throttled | Backend | Step 5 (`HostRateLimiterTest.sameHostIsThrottled`) | |
| 60 | +| Different hosts are not cross-throttled | Backend | Step 5 (`HostRateLimiterTest.differentHostsAreIndependent`) | |
| 61 | +| Transient 5xx is retried then fails | Backend | Step 5 (`PageFetcherTest.persistent5xxIsRetriedThenFails`) | |
| 62 | +| 404 is not retried | Backend | Step 5 (`PageFetcherTest.notFoundIsNotRetried`) | |
| 63 | +| Recovery after transient failure | Backend | Step 5 (`PageFetcherTest.recoversAfterTransientFailure`) | |
| 64 | + |
| 65 | +All scenarios are backend; there is no frontend in this spec. |
0 commit comments