Skip to content
This repository was archived by the owner on Jul 14, 2026. It is now read-only.

Commit 463ce01

Browse files
authored
Merge pull request #49 from bright-room/feat/28-schedule-aware-http-response-with-503-and-retry-after-header
Close #28: Return 503 + Retry-After for schedule-inactive gate denials
2 parents 89bcd9d + 3492f32 commit 463ce01

40 files changed

Lines changed: 919 additions & 99 deletions

File tree

.claude/guidelines/coding.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,3 +315,28 @@ return switch (type) {
315315
- `if/else if/else` チェーンは分岐の追加漏れや順序依存のバグを招きやすい
316316
- `enum` に対する `switch` 式はコンパイラが網羅性を検証するため、安全性が高い
317317
- `if/else if/else` が必要になる状況自体が、型の設計やポリモーフィズムの活用で解消できることが多い
318+
319+
---
320+
321+
## CG-9: `+` 演算子による文字列結合の禁止
322+
323+
文字列結合に `+` 演算子を使用せず、`String.format()` を使用すること。
324+
325+
### NG
326+
327+
```java
328+
throw new IllegalArgumentException(
329+
"Schedule start must not be after end, but start=" + start + " end=" + end);
330+
```
331+
332+
### OK
333+
334+
```java
335+
throw new IllegalArgumentException(
336+
String.format("Schedule start must not be after end, but start=%s end=%s", start, end));
337+
```
338+
339+
### 理由
340+
341+
- `+` 演算子による結合はリテラルと変数が混在すると可読性が低下する
342+
- `String.format()` はテンプレートと引数が分離されるため、メッセージの全体像を把握しやすい

core/src/main/java/net/brightroom/endpointgate/core/evaluation/AccessDecision.java

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
package net.brightroom.endpointgate.core.evaluation;
22

3+
import java.time.Instant;
4+
import net.brightroom.endpointgate.core.exception.EndpointGateAccessDeniedException;
5+
import net.brightroom.endpointgate.core.exception.EndpointGateScheduleInactiveException;
6+
import org.jspecify.annotations.Nullable;
7+
38
/**
49
* Represents the outcome of an endpoint gate evaluation pipeline.
510
*
@@ -11,12 +16,32 @@ public sealed interface AccessDecision {
1116
record Allowed() implements AccessDecision {}
1217

1318
/**
14-
* Indicates that access is denied, with the gate identifier and reason.
19+
* Indicates that access is denied, with the gate identifier, reason, and optional retry-after
20+
* time.
1521
*
1622
* @param gateId the gate identifier that was denied
1723
* @param reason the reason for denial
24+
* @param retryAfter the time after which the client may retry, or {@code null} if not applicable
1825
*/
19-
record Denied(String gateId, DeniedReason reason) implements AccessDecision {}
26+
record Denied(String gateId, DeniedReason reason, @Nullable Instant retryAfter)
27+
implements AccessDecision {
28+
29+
/**
30+
* Converts this denied decision to the appropriate exception type.
31+
*
32+
* <p>If the reason is {@link DeniedReason#SCHEDULE_INACTIVE}, returns a {@link
33+
* EndpointGateScheduleInactiveException}. Otherwise, returns a {@link
34+
* EndpointGateAccessDeniedException}.
35+
*
36+
* @return the exception corresponding to this denial
37+
*/
38+
public EndpointGateAccessDeniedException toException() {
39+
if (reason == DeniedReason.SCHEDULE_INACTIVE) {
40+
return new EndpointGateScheduleInactiveException(gateId, retryAfter);
41+
}
42+
return new EndpointGateAccessDeniedException(gateId);
43+
}
44+
}
2045

2146
/** Reason for denying access in the evaluation pipeline. */
2247
enum DeniedReason {
@@ -40,13 +65,25 @@ static AccessDecision allowed() {
4065
}
4166

4267
/**
43-
* Returns a {@link Denied} decision.
68+
* Returns a {@link Denied} decision with no retry-after time.
4469
*
4570
* @param gateId the gate identifier that was denied
4671
* @param reason the reason for denial
4772
* @return a new {@code Denied} instance
4873
*/
4974
static AccessDecision denied(String gateId, DeniedReason reason) {
50-
return new Denied(gateId, reason);
75+
return new Denied(gateId, reason, null);
76+
}
77+
78+
/**
79+
* Returns a {@link Denied} decision with a retry-after time.
80+
*
81+
* @param gateId the gate identifier that was denied
82+
* @param reason the reason for denial
83+
* @param retryAfter the time after which the client may retry
84+
* @return a new {@code Denied} instance
85+
*/
86+
static AccessDecision denied(String gateId, DeniedReason reason, Instant retryAfter) {
87+
return new Denied(gateId, reason, retryAfter);
5188
}
5289
}

core/src/main/java/net/brightroom/endpointgate/core/evaluation/ScheduleEvaluationStep.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ public Optional<AccessDecision> evaluate(EvaluationContext context) {
2727
return scheduleProvider
2828
.getSchedule(context.gateId())
2929
.filter(schedule -> !schedule.isActive(clock.instant()))
30-
.map(schedule -> AccessDecision.denied(context.gateId(), DeniedReason.SCHEDULE_INACTIVE));
30+
.map(
31+
schedule ->
32+
AccessDecision.denied(
33+
context.gateId(),
34+
DeniedReason.SCHEDULE_INACTIVE,
35+
schedule.retryAfterInstant(clock)));
3136
}
3237
}

core/src/main/java/net/brightroom/endpointgate/core/exception/EndpointGateAccessDeniedException.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,17 @@ public EndpointGateAccessDeniedException(String gateId) {
2323
this.gateId = gateId;
2424
}
2525

26+
/**
27+
* Constructor for subclasses that need to customize the exception message.
28+
*
29+
* @param gateId the identifier of the gate that is not available
30+
* @param message the detail message
31+
*/
32+
protected EndpointGateAccessDeniedException(String gateId, String message) {
33+
super(message);
34+
this.gateId = gateId;
35+
}
36+
2637
/**
2738
* Returns the identifier of the gate that is not available.
2839
*
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package net.brightroom.endpointgate.core.exception;
2+
3+
import java.time.Instant;
4+
import org.jspecify.annotations.Nullable;
5+
6+
/**
7+
* Thrown when access to a gate-protected endpoint is denied because the gate's schedule is not
8+
* currently active.
9+
*
10+
* <p>This is a subclass of {@link EndpointGateAccessDeniedException}, so existing
11+
* {@code @ControllerAdvice} handlers that catch {@code EndpointGateAccessDeniedException} will also
12+
* catch this exception.
13+
*
14+
* <p>The {@link #retryAfter()} method returns the schedule start time as an {@link Instant}, which
15+
* can be used to populate the HTTP {@code Retry-After} header. If the schedule has no configured
16+
* start time (only an end time), {@link #retryAfter()} returns {@code null}.
17+
*/
18+
public class EndpointGateScheduleInactiveException extends EndpointGateAccessDeniedException {
19+
20+
private static final long serialVersionUID = 1L;
21+
22+
private final @Nullable Instant retryAfter;
23+
24+
/**
25+
* Constructor.
26+
*
27+
* @param gateId the identifier of the gate that is not available
28+
* @param retryAfter the schedule start time as an {@link Instant}, or {@code null} if the
29+
* schedule has no start time configured
30+
*/
31+
public EndpointGateScheduleInactiveException(String gateId, @Nullable Instant retryAfter) {
32+
super(gateId, buildMessage(gateId, retryAfter));
33+
this.retryAfter = retryAfter;
34+
}
35+
36+
/**
37+
* Returns the schedule start time as an {@link Instant}, or {@code null} if the schedule has no
38+
* start time configured.
39+
*
40+
* <p>This value can be used to populate the HTTP {@code Retry-After} response header.
41+
*
42+
* @return the retry-after instant, or {@code null}
43+
*/
44+
public @Nullable Instant retryAfter() {
45+
return retryAfter;
46+
}
47+
48+
private static String buildMessage(String gateId, @Nullable Instant retryAfter) {
49+
if (retryAfter == null) {
50+
return String.format("Gate '%s' is not available", gateId);
51+
}
52+
return String.format("Gate '%s' is not available until %s", gateId, retryAfter);
53+
}
54+
}

core/src/main/java/net/brightroom/endpointgate/core/provider/Schedule.java

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package net.brightroom.endpointgate.core.provider;
22

3+
import java.time.Clock;
34
import java.time.Instant;
45
import java.time.LocalDateTime;
56
import java.time.ZoneId;
7+
import java.time.ZonedDateTime;
68
import org.jspecify.annotations.Nullable;
79

810
/**
@@ -35,11 +37,16 @@
3537
public record Schedule(
3638
@Nullable LocalDateTime start, @Nullable LocalDateTime end, @Nullable ZoneId timezone) {
3739

38-
/** Validates that start is not after end. */
40+
/** Validates that start is not after an end. */
3941
public Schedule {
40-
if (start != null && end != null && start.isAfter(end)) {
41-
throw new IllegalArgumentException(
42-
"Schedule start must not be after end, but start=" + start + " end=" + end);
42+
if (start != null) {
43+
if (end != null) {
44+
if (start.isAfter(end)) {
45+
throw new IllegalArgumentException(
46+
String.format(
47+
"Schedule start must not be after end, but start=%s end=%s", start, end));
48+
}
49+
}
4350
}
4451
}
4552

@@ -50,10 +57,47 @@ public record Schedule(
5057
* @return {@code true} if {@code now} falls within the configured window, {@code false} otherwise
5158
*/
5259
public boolean isActive(Instant now) {
53-
ZoneId zone = timezone != null ? timezone : ZoneId.systemDefault();
60+
ZoneId zone = resolveZone();
5461
LocalDateTime localNow = now.atZone(zone).toLocalDateTime();
55-
if (start != null && localNow.isBefore(start)) return false;
56-
if (end != null && localNow.isAfter(end)) return false;
62+
if (start != null) {
63+
if (localNow.isBefore(start)) {
64+
return false;
65+
}
66+
}
67+
if (end != null) {
68+
return !localNow.isAfter(end);
69+
}
5770
return true;
5871
}
72+
73+
/**
74+
* Returns the retry-after {@link Instant} for this schedule, or {@code null} if not applicable.
75+
*
76+
* <p>Returns the {@link #start()} instant if {@code start} is non-null and in the future relative
77+
* to the given {@code clock}. Returns {@code null} if {@code start} is null, or if {@code start}
78+
* is already in the past (to avoid sending a stale retry-after hint to clients).
79+
*
80+
* @param clock the clock used to determine the current time
81+
* @return the retry-after instant, or {@code null}
82+
*/
83+
public @Nullable Instant retryAfterInstant(Clock clock) {
84+
if (start == null) {
85+
return null;
86+
}
87+
ZoneId zone = resolveZone();
88+
ZonedDateTime zonedStart = start.atZone(zone);
89+
Instant startInstant = zonedStart.toInstant();
90+
Instant now = clock.instant();
91+
if (startInstant.isBefore(now)) {
92+
return null;
93+
}
94+
return startInstant;
95+
}
96+
97+
private ZoneId resolveZone() {
98+
if (timezone != null) {
99+
return timezone;
100+
}
101+
return ZoneId.systemDefault();
102+
}
59103
}

core/src/test/java/net/brightroom/endpointgate/core/evaluation/AccessDecisionTest.java

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22

33
import static org.assertj.core.api.Assertions.assertThat;
44

5+
import java.time.Instant;
56
import net.brightroom.endpointgate.core.evaluation.AccessDecision.DeniedReason;
7+
import net.brightroom.endpointgate.core.exception.EndpointGateAccessDeniedException;
8+
import net.brightroom.endpointgate.core.exception.EndpointGateScheduleInactiveException;
69
import org.junit.jupiter.api.Test;
710

811
class AccessDecisionTest {
@@ -20,6 +23,7 @@ void denied_returnsDeniedInstanceWithGateIdAndReason() {
2023
AccessDecision.Denied denied = (AccessDecision.Denied) decision;
2124
assertThat(denied.gateId()).isEqualTo("my-gate");
2225
assertThat(denied.reason()).isEqualTo(DeniedReason.DISABLED);
26+
assertThat(denied.retryAfter()).isNull();
2327
}
2428

2529
@Test
@@ -29,4 +33,52 @@ void denied_supportsAllReasons() {
2933
assertThat(((AccessDecision.Denied) decision).reason()).isEqualTo(reason);
3034
}
3135
}
36+
37+
@Test
38+
void denied_withRetryAfter_storesInstant() {
39+
Instant retryAfter = Instant.parse("2099-01-01T00:00:00Z");
40+
AccessDecision decision =
41+
AccessDecision.denied("my-gate", DeniedReason.SCHEDULE_INACTIVE, retryAfter);
42+
AccessDecision.Denied denied = (AccessDecision.Denied) decision;
43+
assertThat(denied.retryAfter()).isEqualTo(retryAfter);
44+
}
45+
46+
@Test
47+
void toException_returnsScheduleInactiveException_whenReasonIsScheduleInactive() {
48+
Instant retryAfter = Instant.parse("2099-01-01T00:00:00Z");
49+
AccessDecision.Denied denied =
50+
(AccessDecision.Denied)
51+
AccessDecision.denied("my-gate", DeniedReason.SCHEDULE_INACTIVE, retryAfter);
52+
EndpointGateAccessDeniedException e = denied.toException();
53+
assertThat(e).isInstanceOf(EndpointGateScheduleInactiveException.class);
54+
EndpointGateScheduleInactiveException scheduleException =
55+
(EndpointGateScheduleInactiveException) e;
56+
assertThat(scheduleException.gateId()).isEqualTo("my-gate");
57+
assertThat(scheduleException.retryAfter()).isEqualTo(retryAfter);
58+
}
59+
60+
@Test
61+
void toException_returnsAccessDeniedException_whenReasonIsDisabled() {
62+
AccessDecision.Denied denied =
63+
(AccessDecision.Denied) AccessDecision.denied("my-gate", DeniedReason.DISABLED);
64+
EndpointGateAccessDeniedException e = denied.toException();
65+
assertThat(e).isExactlyInstanceOf(EndpointGateAccessDeniedException.class);
66+
assertThat(e.gateId()).isEqualTo("my-gate");
67+
}
68+
69+
@Test
70+
void toException_returnsAccessDeniedException_whenReasonIsConditionNotMet() {
71+
AccessDecision.Denied denied =
72+
(AccessDecision.Denied) AccessDecision.denied("my-gate", DeniedReason.CONDITION_NOT_MET);
73+
EndpointGateAccessDeniedException e = denied.toException();
74+
assertThat(e).isExactlyInstanceOf(EndpointGateAccessDeniedException.class);
75+
}
76+
77+
@Test
78+
void toException_returnsAccessDeniedException_whenReasonIsRolloutExcluded() {
79+
AccessDecision.Denied denied =
80+
(AccessDecision.Denied) AccessDecision.denied("my-gate", DeniedReason.ROLLOUT_EXCLUDED);
81+
EndpointGateAccessDeniedException e = denied.toException();
82+
assertThat(e).isExactlyInstanceOf(EndpointGateAccessDeniedException.class);
83+
}
3284
}

core/src/test/java/net/brightroom/endpointgate/core/evaluation/ScheduleEvaluationStepTest.java

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ void evaluate_returnsEmpty_whenScheduleIsActive() {
4545

4646
@Test
4747
void evaluate_returnsDenied_whenScheduleIsInactive_endInPast() {
48-
// end in past → inactive
48+
// end in past, no start → inactive; retryAfter should be null
4949
Schedule inactive = new Schedule(null, LocalDateTime.of(2025, 1, 1, 0, 0), ZoneId.of("UTC"));
5050
when(scheduleProvider.getSchedule("my-gate")).thenReturn(Optional.of(inactive));
5151

@@ -54,17 +54,34 @@ void evaluate_returnsDenied_whenScheduleIsInactive_endInPast() {
5454
AccessDecision.Denied denied = (AccessDecision.Denied) result.get();
5555
assertThat(denied.gateId()).isEqualTo("my-gate");
5656
assertThat(denied.reason()).isEqualTo(DeniedReason.SCHEDULE_INACTIVE);
57+
assertThat(denied.retryAfter()).isNull();
5758
}
5859

5960
@Test
6061
void evaluate_returnsDenied_whenScheduleIsInactive_startInFuture() {
61-
// start in future → inactive
62-
Schedule inactive = new Schedule(LocalDateTime.of(2025, 12, 1, 0, 0), null, ZoneId.of("UTC"));
62+
// start in future → inactive; retryAfter should be the start instant
63+
LocalDateTime futureStart = LocalDateTime.of(2025, 12, 1, 0, 0);
64+
Schedule inactive = new Schedule(futureStart, null, ZoneId.of("UTC"));
6365
when(scheduleProvider.getSchedule("my-gate")).thenReturn(Optional.of(inactive));
6466

6567
Optional<AccessDecision> result = step.evaluate(CTX);
6668
assertThat(result).isPresent();
67-
assertThat(((AccessDecision.Denied) result.get()).reason())
68-
.isEqualTo(DeniedReason.SCHEDULE_INACTIVE);
69+
AccessDecision.Denied denied = (AccessDecision.Denied) result.get();
70+
assertThat(denied.reason()).isEqualTo(DeniedReason.SCHEDULE_INACTIVE);
71+
assertThat(denied.retryAfter()).isEqualTo(futureStart.atZone(ZoneId.of("UTC")).toInstant());
72+
}
73+
74+
@Test
75+
void evaluate_returnsDenied_withTimezoneAwareRetryAfter_whenTimezoneConfigured() {
76+
// start in future with Tokyo timezone
77+
LocalDateTime futureStart = LocalDateTime.of(2025, 12, 1, 9, 0);
78+
ZoneId tokyo = ZoneId.of("Asia/Tokyo");
79+
Schedule inactive = new Schedule(futureStart, null, tokyo);
80+
when(scheduleProvider.getSchedule("my-gate")).thenReturn(Optional.of(inactive));
81+
82+
Optional<AccessDecision> result = step.evaluate(CTX);
83+
assertThat(result).isPresent();
84+
AccessDecision.Denied denied = (AccessDecision.Denied) result.get();
85+
assertThat(denied.retryAfter()).isEqualTo(futureStart.atZone(tokyo).toInstant());
6986
}
7087
}

0 commit comments

Comments
 (0)