Skip to content

Commit 9b03fea

Browse files
fix tests
1 parent e5bec4e commit 9b03fea

7 files changed

Lines changed: 206 additions & 16 deletions

File tree

CLAUDE.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,30 @@ When picking up new work, check this list before reaching for the SDK requiremen
9494
- `HttpTransport.buildUri` URL-encodes query-param values with `URLEncoder.encode(..., UTF_8)`, which is form-encoding semantics: spaces become `+`, not `%20`. Fine for today's typed params (dates, numerics) but a future endpoint that takes an arbitrary string (e.g. `symbol="BRK A"`) would round-trip differently against an RFC-3986-strict server. Switch to a path/query-segment-aware encoder when the first such param lands. Tracked as Issue #10 of the 2026-05-11 review.
9595
- `Retry-After` server header is parsed and respected by neither `RetryPolicy` nor `HttpTransport`. Today every retry uses the calculated exponential backoff (`min(1s × 2^N, 30s)`). Implementing the override needs the response headers to reach `RetryPolicy.backoffDelay`, which today only sees the attempt index — most natural path is to surface a `Duration` on `ServerError` (or thread it through a separate channel) when 5xx responses carry the header. Follow-up of the §9 work.
9696

97+
## Test policy: unit tests are network-free
98+
99+
Unit tests under `src/test/` **must not** make real network calls. Anything that needs the live
100+
API goes to `src/integrationTest/` (gated by `MARKETDATA_RUN_INTEGRATION_TESTS=true`).
101+
102+
This matters because §5 made `MarketDataClient`'s constructor perform a `GET /user/` when
103+
`validateOnStartup=true` (the default). A test that builds `new MarketDataClient("any-token",
104+
null, null, true)` now hits `api.marketdata.app` over the network — exactly the failure mode
105+
this policy prevents.
106+
107+
Concrete rules for unit tests that construct `MarketDataClient`:
108+
109+
- Use the 4-arg constructor with `validateOnStartup=false` when verifying field wiring only.
110+
- Use a local server (`com.sun.net.httpserver.HttpServer` in-process) and point `baseUrl` at it
111+
when you genuinely need to exercise the validate-on-startup flow — see
112+
`MarketDataClientStartupValidationTest` for the pattern.
113+
- Tests that need the **real** API for smoke verification (e.g. the no-arg ctor end-to-end) go to
114+
`src/integrationTest/.../MarketDataClientIT.java`.
115+
116+
No automated enforcement today (a future ArchUnit rule or a grep step in CI could close that
117+
gap). Code review is the gate. Whenever you introduce a test that touches `new
118+
MarketDataClient(..., true)` with a default URL, ask: "does this need to live in `integrationTest`
119+
instead?"
120+
97121
## Acceptance checklist
98122

99123
`docs/java-sdk-requirements.md` ends with an "Acceptance Checklist" mapping each Java-specific requirements section to verifiable items. Treat it as the definition of done for v1: when implementing, work toward making each box checkable, and use it as a self-review pass before declaring a section complete.

src/main/java/com/marketdata/sdk/HttpTransport.java

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,14 @@ private static HttpClient defaultHttpClient() {
121121
* RetryPolicy}: retries 501–599 and IOException-shaped {@link NetworkError}s with exponential
122122
* backoff, surfaces every other failure immediately. Cancellation of the returned future bails
123123
* out of any pending backoff and propagates to the current in-flight attempt.
124+
*
125+
* <p><strong>Interaction between the §8 pre-flight check and the retry chain:</strong> each
126+
* attempt re-runs the pre-flight check against the latest rate-limit snapshot. If a transient 5xx
127+
* triggers a backoff and another concurrent caller's response drains the snapshot to {@code
128+
* remaining=0} during that window, the next attempt's pre-flight fires and the chain ends with
129+
* {@link com.marketdata.sdk.exception.RateLimitError} — not the upstream 5xx. The synthetic error
130+
* reflects what the server would have returned anyway (a guaranteed 429 once the quota was gone),
131+
* but callers that grep for "last server status" should know the substitution can happen.
124132
*/
125133
<T> CompletableFuture<T> executeAsync(RequestSpec spec, Class<T> responseType) {
126134
CompletableFuture<T> result = new CompletableFuture<>();
@@ -219,11 +227,17 @@ private <T> CompletableFuture<T> executeOnce(RequestSpec spec, Class<T> response
219227
// seeds it, so this is a no-op for cold clients.
220228
RateLimits snapshot = latestRateLimits.get();
221229
if (snapshot != null && snapshot.remaining() <= 0) {
230+
// `reset == EPOCH` means the response carried partial rate-limit headers (e.g.
231+
// remaining without reset) and `RateLimitHeaders.parse` defaulted the missing field to
232+
// 0. Rendering "1970-01-01" in the user-facing message looks like a bug; omit the
233+
// suffix when the value is meaningless.
234+
String resetSuffix =
235+
snapshot.reset().equals(java.time.Instant.EPOCH)
236+
? ""
237+
: " (resets at " + snapshot.reset() + ")";
222238
return CompletableFuture.failedFuture(
223239
new RateLimitError(
224-
"Pre-flight rate-limit check failed: 0 credits remaining (resets at "
225-
+ snapshot.reset()
226-
+ ")",
240+
"Pre-flight rate-limit check failed: 0 credits remaining" + resetSuffix,
227241
new ErrorContext(null, uri.toString(), null)));
228242
}
229243

src/main/java/com/marketdata/sdk/MarketDataClient.java

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,32 @@ private static String trimTrailingSlash(String url) {
214214
* <p>When the env var is unset (or blank, or malformed), the method is a no-op — the SDK inherits
215215
* whatever {@code java.util.logging} configuration the host JVM has, which is what library code
216216
* is expected to do.
217+
*
218+
* <p><strong>Side effect when the env var is set:</strong> the SDK logger's {@code
219+
* useParentHandlers} is flipped to {@code false}, so handlers attached to the root logger no
220+
* longer receive {@code com.marketdata.sdk.*} records. That prevents double emission (root
221+
* handler with the JVM default formatter + our handler with the spec format) but means consumers
222+
* that previously intercepted all logs through a root handler will stop seeing SDK records. To
223+
* re-route, attach the consumer's handler directly to the SDK logger:
224+
*
225+
* <pre>{@code
226+
* Logger.getLogger("com.marketdata.sdk").addHandler(myHandler);
227+
* }</pre>
228+
*
229+
* <p><strong>Records emitted before this method completes</strong> use whatever formatter the JVM
230+
* has by default, not {@link MarketDataLogFormatter}. Two paths produce such records:
231+
*
232+
* <ul>
233+
* <li>{@link Configuration#loadFromProcess()} runs first in the constructor and may warn about
234+
* a malformed {@code .env} file before {@code configureLogging} has had a chance to install
235+
* the formatter.
236+
* <li>The {@code "Ignoring invalid MARKETDATA_LOGGING_LEVEL=…"} warning below fires while the
237+
* method is bailing out — by definition before any handler gets installed.
238+
* </ul>
239+
*
240+
* These edges are accepted: warnings still reach the user (default JVM level is {@code INFO} ≤
241+
* {@code WARNING}); they just don't carry the spec timestamp prefix. Spec-shaped output starts
242+
* with the first record emitted after this method returns normally.
217243
*/
218244
static void configureLogging(Configuration config) {
219245
String requestedLevel = config.resolve(null, EnvVars.LOGGING_LEVEL);
@@ -230,16 +256,15 @@ static void configureLogging(Configuration config) {
230256
Logger sdkLogger = Logger.getLogger(SDK_LOGGER_NAME);
231257
sdkLogger.setLevel(parsed);
232258
if (!hasSdkHandler(sdkLogger)) {
233-
Handler handler = new java.util.logging.ConsoleHandler();
234-
handler.setFormatter(new MarketDataLogFormatter());
259+
Handler handler = new MarketDataConsoleHandler();
235260
handler.setLevel(parsed);
236261
sdkLogger.addHandler(handler);
237262
// Bypass the root logger's default handlers; they would otherwise re-emit each record
238263
// with the JVM default formatter and produce duplicate, badly-shaped lines.
239264
sdkLogger.setUseParentHandlers(false);
240265
} else {
241266
for (Handler h : sdkLogger.getHandlers()) {
242-
if (isSdkHandler(h)) {
267+
if (h instanceof MarketDataConsoleHandler) {
243268
h.setLevel(parsed);
244269
}
245270
}
@@ -248,14 +273,10 @@ static void configureLogging(Configuration config) {
248273

249274
private static boolean hasSdkHandler(Logger logger) {
250275
for (Handler h : logger.getHandlers()) {
251-
if (isSdkHandler(h)) {
276+
if (h instanceof MarketDataConsoleHandler) {
252277
return true;
253278
}
254279
}
255280
return false;
256281
}
257-
258-
private static boolean isSdkHandler(Handler h) {
259-
return h.getFormatter() instanceof MarketDataLogFormatter;
260-
}
261282
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.marketdata.sdk;
2+
3+
import java.util.logging.ConsoleHandler;
4+
5+
/**
6+
* Marker subclass of {@link ConsoleHandler} so {@link MarketDataClient#configureLogging} can tell
7+
* its own handler apart from anything the host application or another library installed on the same
8+
* logger. The previous discriminator inspected {@code getFormatter() instanceof
9+
* MarketDataLogFormatter}, which is structurally accurate today but trips up the rare case of a
10+
* consumer attaching {@code MarketDataLogFormatter} to a vanilla {@code ConsoleHandler}.
11+
*
12+
* <p>No behavior beyond {@link ConsoleHandler}; the type identity is the whole point.
13+
*/
14+
final class MarketDataConsoleHandler extends ConsoleHandler {
15+
16+
MarketDataConsoleHandler() {
17+
setFormatter(new MarketDataLogFormatter());
18+
}
19+
}

src/test/java/com/marketdata/sdk/HttpTransportRetryTest.java

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,29 @@ void preflightFailsImmediatelyWhenRemainingIsZero() {
309309
.isEqualTo(1);
310310
}
311311

312+
@Test
313+
void preflightMessageOmitsResetSuffixWhenResetIsEpoch() {
314+
// Partial rate-limit headers: server returned `remaining` without a `reset`, so
315+
// RateLimitHeaders.parse defaulted reset to Instant.EPOCH. Rendering "(resets at
316+
// 1970-01-01T00:00:00Z)" in the user-facing message looks like a bug — verify we omit it.
317+
MultiResponseHttpClient client =
318+
new MultiResponseHttpClient(
319+
response(
320+
200,
321+
"{\"value\":\"ok\"}",
322+
Map.of(
323+
"x-api-ratelimit-limit", "50000",
324+
"x-api-ratelimit-remaining", "0",
325+
"x-api-ratelimit-consumed", "50000")));
326+
327+
HttpTransport transport = newTransport(client, fastPolicy(3));
328+
transport.executeSync(RequestSpec.get("ping").build(), Echo.class);
329+
330+
assertThatThrownBy(() -> transport.executeSync(RequestSpec.get("ping").build(), Echo.class))
331+
.isInstanceOf(RateLimitError.class)
332+
.hasMessage("Pre-flight rate-limit check failed: 0 credits remaining");
333+
}
334+
312335
@Test
313336
void preflightAllowsRequestWhenRemainingPositive() {
314337
MultiResponseHttpClient client =

src/test/java/com/marketdata/sdk/MarketDataClientLoggingTest.java

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ void appliesLevelFromEnvVar() {
6868

6969
assertThat(sdkLogger.getLevel()).isEqualTo(Level.FINE);
7070
assertThat(Arrays.stream(sdkLogger.getHandlers()))
71-
.anyMatch(h -> h.getFormatter() instanceof MarketDataLogFormatter);
71+
.anyMatch(h -> h instanceof MarketDataConsoleHandler);
7272
assertThat(sdkLogger.getUseParentHandlers())
7373
.as("our handler bypasses parent so we don't double-emit with the JVM default formatter")
7474
.isFalse();
@@ -90,15 +90,15 @@ void calledTwiceDoesNotDuplicateHandler() {
9090

9191
long sdkHandlers =
9292
Arrays.stream(sdkLogger.getHandlers())
93-
.filter(h -> h.getFormatter() instanceof MarketDataLogFormatter)
93+
.filter(h -> h instanceof MarketDataConsoleHandler)
9494
.count();
9595
assertThat(sdkHandlers)
9696
.as("second call should refresh the level, not add a second handler")
9797
.isEqualTo(1);
9898
assertThat(sdkLogger.getLevel()).isEqualTo(Level.WARNING);
9999
// The handler's own level should track the latest call too.
100100
Arrays.stream(sdkLogger.getHandlers())
101-
.filter(h -> h.getFormatter() instanceof MarketDataLogFormatter)
101+
.filter(h -> h instanceof MarketDataConsoleHandler)
102102
.forEach(h -> assertThat(h.getLevel()).isEqualTo(Level.WARNING));
103103
}
104104

@@ -122,7 +122,72 @@ void noOpWhenEnvVarIsBlank() {
122122
MarketDataClient.configureLogging(newConfig(Map.of(EnvVars.LOGGING_LEVEL, " ")));
123123

124124
assertThat(Arrays.stream(sdkLogger.getHandlers()))
125-
.noneMatch(h -> h.getFormatter() instanceof MarketDataLogFormatter);
125+
.noneMatch(h -> h instanceof MarketDataConsoleHandler);
126+
}
127+
128+
// ---------- end-to-end: env var → real log emission with spec shape ----------
129+
130+
/**
131+
* Closes the loop between {@link MarketDataClient#configureLogging} and {@link
132+
* MarketDataLogFormatter}. The other tests in this class verify the mechanics of configureLogging
133+
* (level applied, handler installed) and {@code MarketDataLogFormatterTest} verifies the
134+
* formatter shape in isolation — but neither alone catches a regression where the two stop
135+
* composing (e.g. configureLogging starts using a different formatter, or the level filter blocks
136+
* records the formatter would have rendered).
137+
*
138+
* <p>Strategy: let {@code configureLogging} install its handler, then add a parallel capturing
139+
* handler that reuses the same {@link MarketDataLogFormatter} so we observe the same line that
140+
* goes to stderr. Emit a record on a child of the SDK logger and assert the captured line matches
141+
* the spec shape exactly.
142+
*/
143+
@Test
144+
void emittedRecordsAreFormattedPerSpec() {
145+
MarketDataClient.configureLogging(newConfig(Map.of(EnvVars.LOGGING_LEVEL, "FINE")));
146+
147+
java.util.logging.Handler installed =
148+
Arrays.stream(sdkLogger.getHandlers())
149+
.filter(h -> h instanceof MarketDataConsoleHandler)
150+
.findFirst()
151+
.orElseThrow(() -> new AssertionError("configureLogging did not install its handler"));
152+
153+
CapturingHandler capture = new CapturingHandler();
154+
capture.setFormatter(installed.getFormatter());
155+
capture.setLevel(Level.ALL);
156+
sdkLogger.addHandler(capture);
157+
158+
Logger child = Logger.getLogger("com.marketdata.sdk.example");
159+
child.fine("hello world");
160+
161+
assertThat(capture.formattedLines)
162+
.as("end-to-end logging must produce the spec-mandated shape")
163+
.anyMatch(
164+
line ->
165+
line.matches(
166+
"\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z"
167+
+ " - com\\.marketdata\\.sdk\\.example - FINE - hello world"
168+
+ java.util.regex.Pattern.quote(System.lineSeparator())));
169+
}
170+
171+
/**
172+
* Minimal {@link java.util.logging.Handler} that runs the configured formatter and stashes the
173+
* rendered string. Tests assert against {@link #formattedLines}; the raw records are not exposed
174+
* because the formatter is what we actually care about end-to-end.
175+
*/
176+
private static final class CapturingHandler extends java.util.logging.Handler {
177+
final java.util.List<String> formattedLines = new java.util.ArrayList<>();
178+
179+
@Override
180+
public void publish(java.util.logging.LogRecord record) {
181+
if (isLoggable(record)) {
182+
formattedLines.add(getFormatter().format(record));
183+
}
184+
}
185+
186+
@Override
187+
public void flush() {}
188+
189+
@Override
190+
public void close() {}
126191
}
127192

128193
@Test
@@ -133,6 +198,6 @@ void noOpAndWarnsWhenEnvVarIsInvalid() {
133198

134199
assertThat(Arrays.stream(sdkLogger.getHandlers()))
135200
.as("invalid level must not install a handler — that would lie about the SDK's config")
136-
.noneMatch(h -> h.getFormatter() instanceof MarketDataLogFormatter);
201+
.noneMatch(h -> h instanceof MarketDataConsoleHandler);
137202
}
138203
}

src/test/java/com/marketdata/sdk/MarketDataClientStartupValidationTest.java

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import com.marketdata.sdk.exception.AuthenticationError;
88
import com.marketdata.sdk.exception.NetworkError;
9+
import com.marketdata.sdk.exception.ServerError;
910
import com.sun.net.httpserver.HttpHandler;
1011
import com.sun.net.httpserver.HttpServer;
1112
import java.io.IOException;
@@ -84,6 +85,29 @@ void surfaceAuthenticationErrorImmediatelyWhenTokenIsRejected() {
8485
.isEqualTo(1);
8586
}
8687

88+
/**
89+
* Regression: {@code validateToken} must be single-attempt. {@code HttpTransport.executeAsync}
90+
* normally retries 503 with exponential backoff, so if a refactor accidentally routes the startup
91+
* probe through it the server would see 3 calls and (in this test) eventually succeed with a 200.
92+
* Forcing the test to script enough responses to satisfy a 3-attempt chain — and then asserting
93+
* {@code requestCount == 1} — fails determinístically the moment the path stops being
94+
* single-shot.
95+
*/
96+
@Test
97+
void validateTokenDoesNotRetryTransient5xx() {
98+
route(503, "{}");
99+
100+
assertThatThrownBy(() -> new MarketDataClient("token", baseUrl(), null, true))
101+
.isInstanceOf(ServerError.class)
102+
.satisfies(t -> assertThat(((ServerError) t).getStatusCode()).isEqualTo(503));
103+
104+
assertThat(requestCount.get())
105+
.as(
106+
"validateToken must call executeOnce (not executeAsync) so a refactor that"
107+
+ " accidentally enables retry on the startup path is caught here")
108+
.isEqualTo(1);
109+
}
110+
87111
@Test
88112
void surfacesNetworkErrorWhenServerUnreachable() throws IOException {
89113
// Bind+close an ephemeral port to get a "nothing is listening" URL.

0 commit comments

Comments
 (0)