|
| 1 | +# Feature Spec 004: Retry-After Time Parsing |
| 2 | + |
| 3 | +**Status:** Completed |
| 4 | +**Created:** 2025-10-29 |
| 5 | +**Author:** Codex |
| 6 | +**Last Updated:** 2025-10-29 |
| 7 | + |
| 8 | +## Overview |
| 9 | + |
| 10 | +`ol.clave.impl.http/retry-after` currently trims the `Retry-After` header down to either a delta-seconds string or punts to the stubbed `parse-http-time`. RFC 7231 (§7.1.1.1) permits three HTTP-date wire formats (IMF-fixdate, obsolete RFC 850, and ANSI C's asctime). We need a tolerant parser that accepts all three, normalises them to an `Instant`, and keeps higher-level retry helpers deterministic and testable. |
| 11 | + |
| 12 | +Meeting this milestone ensures we sleep the correct amount when Pebble (or real CAs) respond with Retry-After hints and gives callers the surfaced `Duration` they need for backoff policies. |
| 13 | + |
| 14 | +## Competitive Analysis: acme4j |
| 15 | + |
| 16 | +`extra/acme4j/acme4j-client/src/main/java/org/shredzone/acme4j/connector/DefaultConnection.java` parses Retry-After by: |
| 17 | + |
| 18 | +- Treating digit-only headers as delta-seconds applied to the HTTP `Date` header when present, falling back to `Instant.now()` if absent. |
| 19 | +- Parsing absolute HTTP-date values strictly with `DateTimeFormatter.RFC_1123_DATE_TIME` (IMF-fixdate). |
| 20 | +- Raising a protocol error on malformed headers. |
| 21 | + |
| 22 | +`DefaultConnectionTest` exercises both header flavours and ensures delta-seconds honour the response `Date`. We'll mirror the Date-header preference but keep our broader HTTP-date support and nil-on-invalid behaviour so callers can fall back gracefully. |
| 23 | + |
| 24 | +## Implementation Comparison |
| 25 | + |
| 26 | +| Aspect | acme4j | ol.clave | |
| 27 | +|-------------------------|-----------------------------------------------------------|---------------------------------------------------------------------------------------------------------| |
| 28 | +| Delta-seconds baseline | Prefers HTTP `Date` header, falls back to `Instant.now()` | Same behaviour, exposed via `retry-after-header->instant` using `parse-http-time` for the `Date` header | |
| 29 | +| Absolute date parsing | Strict RFC 1123 (`DateTimeFormatter.RFC_1123_DATE_TIME`) | Accepts IMF-fixdate, RFC 850, and ANSI C asctime via formatter suite | |
| 30 | +| Invalid header handling | Throws `AcmeProtocolException` | Returns `nil` so callers can fall back to supplied duration | |
| 31 | +| Test coverage | Delta vs date, absolute date, absence | Mirrors delta + date coupling, plus additional variants for all HTTP-date forms and fallback paths | |
| 32 | +| Time source | `Instant.now()` inline | Private `now` helper to enable deterministic testing | |
| 33 | + |
| 34 | +## Goals |
| 35 | + |
| 36 | +1. Parse all RFC 7231 HTTP-date variants into `java.time.Instant` without relying on deprecated `java.util.Date`. |
| 37 | +2. Prefer the server `Date` header as the baseline instant for delta-seconds retries, falling back to `Instant/now` if missing or invalid. |
| 38 | +3. Keep `retry-after-time` and `retry-after` deterministic for testing by routing `Instant/now` through an overridable helper. |
| 39 | +4. Provide regression tests covering delta-seconds (with and without `Date`), each HTTP-date flavour, future/past handling, and invalid header fallbacks. |
| 40 | + |
| 41 | +## Non-Goals |
| 42 | + |
| 43 | +- Implement broader HTTP header parsing or general date utilities. |
| 44 | +- Change public API shapes outside `ol.clave.impl.http` plumbing namespace. |
| 45 | +- Add resilience features (e.g. jitter) beyond reading Retry-After correctly. |
| 46 | + |
| 47 | +## Implementation Plan |
| 48 | + |
| 49 | +1. **Formatter suite** |
| 50 | + - Build a private vector of `DateTimeFormatter` instances created via `DateTimeFormatterBuilder` to cover: |
| 51 | + - `EEE, dd MMM yyyy HH:mm:ss 'GMT'` (IMF-fixdate / RFC 1123). |
| 52 | + - `EEEE, dd-MMM-yy HH:mm:ss 'GMT'` (obsolete RFC 850; ensure two-digit year mapping per RFC rules). |
| 53 | + - `EEE MMM d HH:mm:ss yyyy` (ANSI C's asctime; map implicit GMT). |
| 54 | + - Wrap them with `.withZone ZoneOffset/UTC` to ensure parsed instants are UTC. |
| 55 | + - Iterate formatters until one succeeds; return first successful `Instant`, else `nil`. |
| 56 | + |
| 57 | +2. **Parsing helper** |
| 58 | + - Implement `parse-http-time` to: |
| 59 | + - Guard against blank inputs. |
| 60 | + - Try each formatter inside `try`/`catch`, ignoring `DateTimeParseException` until success. |
| 61 | + - Reject two-digit years earlier than 1970 by rolling per RFC (>=1970 vs +100 years) or simply using formatter with resolver style `STRICT`. |
| 62 | + - Return `nil` for garbage input. |
| 63 | + |
| 64 | +3. **Now shim** |
| 65 | + - Add private `(defn- now [] (Instant/now))` and replace direct `Instant/now` calls in `retry-after-time` and `retry-after`. |
| 66 | + - Tests can `with-redefs` `now` to supply deterministic instants. |
| 67 | + |
| 68 | +4. **Retry helper adjustments** |
| 69 | + - Refine the private `retry-after-header->instant` helper so it accepts the full response map (not just the raw header) and can read both `Retry-After` and `Date`. |
| 70 | + - Keep public `retry-after-time` focused on response maps and delegate to the helper. |
| 71 | + - When header is delta-seconds, parse the server `Date` header via `parse-http-time`, falling back to `(now)` if parsing fails. |
| 72 | + - When header contains HTTP-date, delegate to `parse-http-time`. |
| 73 | + - Keep nil-on-exception behaviour. |
| 74 | + - Leave `retry-after` signature intact but depend on `now` shim for comparisons. |
| 75 | + |
| 76 | +5. **Testing** |
| 77 | + - New unit tests in `test/ol/clave/impl/http_test.clj`: |
| 78 | + - `parse-http-time` accepts each HTTP-date flavour (with sample strings from RFC 7231). |
| 79 | + - Returns `nil` for invalid strings / nil input. |
| 80 | + - `retry-after-time` handles delta seconds using the `Date` header baseline, delta seconds without `Date` (falls back to mocked `(now)`), and HTTP-date values. |
| 81 | + - `retry-after` computes zero-duration when Retry-After <= now, otherwise the correct positive duration. |
| 82 | + - Tests should be verbose and validate full map equality when asserting results. |
| 83 | + |
| 84 | +6. **Documentation & Roadmap** |
| 85 | + - Update ROADMAP milestone item to checked. |
| 86 | + - Note implementation details in spec (this file) if adjustments are required during development. |
| 87 | + |
| 88 | +## Test Matrix |
| 89 | + |
| 90 | +| Scenario | Header Example | Expected Result | |
| 91 | +|---------------------|-----------------------------------------------------------|---------------------------------------------------------------------------------------| |
| 92 | +| Delta seconds | `Retry-After: 30` | `retry-after` returns `Duration/ofSeconds 30` when `now` mocked to the same baseline. | |
| 93 | +| IMF-fixdate | `Retry-After: Wed, 21 Oct 2015 07:28:00 GMT` | Parsed instant equals RFC sample. | |
| 94 | +| RFC 850 | `Retry-After: Wednesday, 21-Oct-15 07:28:00 GMT` | Parsed instant equals IMF-fixdate equivalent. | |
| 95 | +| asctime | `Retry-After: Wed Oct 21 07:28:00 2015` | Parsed instant equals IMF-fixdate equivalent. | |
| 96 | +| Past date | Header one minute in past | `retry-after` returns `Duration/ZERO`. | |
| 97 | +| Invalid | `Retry-After: nonsense` | `retry-after-time` returns `nil`, `retry-after` falls back to provided duration. | |
| 98 | +| Delta + Date header | `Retry-After: 120`, `Date: Wed, 21 Oct 2015 07:26:00 GMT` | Instant equals `07:28:00Z`. | |
| 99 | + |
| 100 | +## Outcomes |
| 101 | + |
| 102 | +- Correct Retry-After handling across all compliant server variants. |
| 103 | +- Deterministic tests for time-sensitive logic. |
| 104 | +- Clear path for future enhancements (e.g. jitter) built atop accurate duration calculations. |
0 commit comments