-
Notifications
You must be signed in to change notification settings - Fork 0
feat(fx): add FrankfurterRateAdapter for free FX rates (STA-206) #233
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| package com.stablecoin.payments.fx.infrastructure.provider.frankfurter; | ||
|
|
||
| import org.springframework.boot.context.properties.ConfigurationProperties; | ||
|
|
||
| @ConfigurationProperties(prefix = "app.fx.frankfurter") | ||
| public record FrankfurterProperties( | ||
| String baseUrl, | ||
| Integer readTimeoutMs | ||
| ) { | ||
| public FrankfurterProperties { | ||
| if (baseUrl == null || baseUrl.isBlank()) { | ||
| baseUrl = "https://api.frankfurter.app"; | ||
| } | ||
| if (readTimeoutMs == null || readTimeoutMs <= 0) { | ||
| readTimeoutMs = 5000; | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,92 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| package com.stablecoin.payments.fx.infrastructure.provider.frankfurter; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import com.stablecoin.payments.fx.domain.model.CorridorRate; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import com.stablecoin.payments.fx.domain.port.RateProvider; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import io.github.resilience4j.retry.annotation.Retry; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import lombok.extern.slf4j.Slf4j; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.boot.context.properties.EnableConfigurationProperties; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.http.client.JdkClientHttpRequestFactory; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.stereotype.Component; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.web.client.RestClient; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import java.net.http.HttpClient; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import java.net.http.HttpClient.Version; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import java.time.Duration; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import java.util.Optional; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @Slf4j | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @Component | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @ConditionalOnProperty(name = "app.fx.rate-provider", havingValue = "frankfurter") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @EnableConfigurationProperties(FrankfurterProperties.class) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| public class FrankfurterRateAdapter implements RateProvider { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private static final int DEFAULT_SPREAD_BPS = 30; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private static final int DEFAULT_FEE_BPS = 30; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private final RestClient restClient; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| public FrankfurterRateAdapter(FrankfurterProperties properties) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| var httpClient = HttpClient.newBuilder() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .version(Version.HTTP_1_1) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .connectTimeout(Duration.ofMillis(properties.readTimeoutMs())) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .build(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| var requestFactory = new JdkClientHttpRequestFactory(httpClient); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| requestFactory.setReadTimeout(Duration.ofMillis(properties.readTimeoutMs())); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| this.restClient = RestClient.builder() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .baseUrl(properties.baseUrl()) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .requestFactory(requestFactory) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .build(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+30
to
+43
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Line 33 sets 🔧 Separate connect and read timeouts public FrankfurterRateAdapter(FrankfurterProperties properties) {
var httpClient = HttpClient.newBuilder()
.version(Version.HTTP_1_1)
- .connectTimeout(Duration.ofMillis(properties.readTimeoutMs()))
+ .connectTimeout(Duration.ofMillis(properties.connectTimeoutMs()))
.build();This requires adding 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @Override | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @Retry(name = "frankfurter", fallbackMethod = "frankfurterFallback") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @CircuitBreaker(name = "frankfurter", fallbackMethod = "frankfurterFallback") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| public Optional<CorridorRate> getRate(String fromCurrency, String toCurrency) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| log.info("[FRANKFURTER] Fetching rate for {}:{}", fromCurrency, toCurrency); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| var response = restClient.get() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .uri("/latest?from={from}&to={to}", fromCurrency, toCurrency) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .retrieve() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .body(FrankfurterRateResponse.class); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (response == null || response.rates() == null || response.rates().isEmpty()) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| log.warn("[FRANKFURTER] No rate returned for {}:{}", fromCurrency, toCurrency); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return Optional.empty(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+48
to
+59
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial Consider DEBUG level for routine request logging. Line 49 logs at INFO for every rate fetch. In production with frequent polling, this creates significant log volume. Consider DEBUG for the request and reserve INFO for warnings/errors. ♻️ Reduce log verbosity- log.info("[FRANKFURTER] Fetching rate for {}:{}", fromCurrency, toCurrency);
+ log.debug("[FRANKFURTER] Fetching rate for {}:{}", fromCurrency, toCurrency);📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| var rate = response.rates().get(toCurrency); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (rate == null) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| log.warn("[FRANKFURTER] Rate for {} not found in response for {}:{}", toCurrency, fromCurrency, toCurrency); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return Optional.empty(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| var corridorRate = CorridorRate.builder() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .fromCurrency(fromCurrency) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .toCurrency(toCurrency) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .rate(rate) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .spreadBps(DEFAULT_SPREAD_BPS) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .feeBps(DEFAULT_FEE_BPS) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .provider("frankfurter") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .ageMs(0) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .build(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| log.info("[FRANKFURTER] Rate fetched {}:{}={}", fromCurrency, toCurrency, rate); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return Optional.of(corridorRate); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+67
to
+79
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial
The Frankfurter API returns only a date, not a timestamp, so actual rate staleness cannot be determined. Hardcoding 📝 Add explanatory comment var corridorRate = CorridorRate.builder()
.fromCurrency(fromCurrency)
.toCurrency(toCurrency)
.rate(rate)
.spreadBps(DEFAULT_SPREAD_BPS)
.feeBps(DEFAULT_FEE_BPS)
.provider("frankfurter")
+ // Frankfurter API provides date only (no timestamp), so ageMs cannot be accurately determined
.ageMs(0)
.build();📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @Override | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| public String providerName() { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return "frankfurter"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @SuppressWarnings("unused") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private Optional<CorridorRate> frankfurterFallback(String fromCurrency, String toCurrency, Exception ex) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| log.error("[FRANKFURTER] Resilience fallback for {}:{} due to {}", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| fromCurrency, toCurrency, ex.getClass().getSimpleName(), ex); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return Optional.empty(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+86
to
+91
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Find usages of RateProvider.getRate and check how empty is handled
ast-grep --pattern 'getRate($_, $_)' | head -50
rg -n -A5 'getRate\(' --type=java -g '!*Test*' -g '!FrankfurterRateAdapter.java'Repository: Puneethkumarck/stablebridge-platform Length of output: 6316 🏁 Script executed: #!/bin/bash
# Check FxQuoteApplicationService full method context around getRate call
rg -n -B10 -A15 'rateProvider.getRate' fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/application/service/FxQuoteApplicationService.java | head -60Repository: Puneethkumarck/stablebridge-platform Length of output: 1561 🏁 Script executed: #!/bin/bash
# Check RateRefreshJob full method context
rg -n -B5 -A15 'rateProvider.getRate' fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/infrastructure/scheduling/RateRefreshJob.javaRepository: Puneethkumarck/stablebridge-platform Length of output: 1084 🏁 Script executed: #!/bin/bash
# Check how FrankfurterRateAdapter.getRate is decorated (circuit breaker, resilience4j)
rg -n -B20 'public Optional<CorridorRate> getRate' fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/infrastructure/provider/frankfurter/FrankfurterRateAdapter.java | head -40Repository: Puneethkumarck/stablebridge-platform Length of output: 1041 🏁 Script executed: #!/bin/bash
# Verify frankfurterFallback is used as fallback method in circuit breaker config
rg -n 'frankfurterFallback\|@CircuitBreaker\|@Fallback' fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/infrastructure/provider/frankfurter/FrankfurterRateAdapter.javaRepository: Puneethkumarck/stablebridge-platform Length of output: 62 Circuit breaker fallback returns Both 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| package com.stablecoin.payments.fx.infrastructure.provider.frankfurter; | ||
|
|
||
| import java.math.BigDecimal; | ||
| import java.util.Map; | ||
|
|
||
| record FrankfurterRateResponse( | ||
| BigDecimal amount, | ||
| String base, | ||
| String date, | ||
| Map<String, BigDecimal> rates | ||
| ) {} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -127,6 +127,11 @@ app: | |
| resilience4j: | ||
| circuitbreaker: | ||
| circuit-breaker-aspect-order: 1 | ||
| instances: | ||
| frankfurter: | ||
| slidingWindowSize: 10 | ||
| failureRateThreshold: 50 | ||
| waitDurationInOpenState: 30s | ||
| retry: | ||
| retry-aspect-order: 3 | ||
| instances: | ||
|
|
@@ -144,6 +149,18 @@ resilience4j: | |
| ignore-exceptions: | ||
| - org.springframework.web.client.HttpClientErrorException | ||
| - io.github.resilience4j.circuitbreaker.CallNotPermittedException | ||
| frankfurter: | ||
| maxAttempts: 3 | ||
| waitDuration: 1s | ||
| retry-exceptions: | ||
| - java.io.IOException | ||
| - java.net.http.HttpTimeoutException | ||
| - java.net.http.HttpConnectTimeoutException | ||
| - org.springframework.web.client.ResourceAccessException | ||
| - org.springframework.web.client.HttpServerErrorException | ||
| ignore-exceptions: | ||
| - org.springframework.web.client.HttpClientErrorException | ||
| - io.github.resilience4j.circuitbreaker.CallNotPermittedException | ||
|
Comment on lines
+152
to
+163
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial Inconsistent property naming style in retry configuration. The Also, ♻️ Align naming style with existing config frankfurter:
- maxAttempts: 3
- waitDuration: 1s
+ max-attempts: 3
+ wait-duration: 1s
+ enable-exponential-backoff: true
+ exponential-backoff-multiplier: 2
retry-exceptions:🤖 Prompt for AI Agents |
||
|
|
||
| logging: | ||
| level: | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick | 🔵 Trivial
Properties record is well-structured with sensible defaults.
Compact constructor with null/blank checks and defaults is a clean pattern. Consider adding a separate
connectTimeoutMsproperty—the adapter currently reusesreadTimeoutMsfor both connect and read timeouts, which conflates two distinct concerns.♻️ Add separate connect timeout property
`@ConfigurationProperties`(prefix = "app.fx.frankfurter") public record FrankfurterProperties( String baseUrl, - Integer readTimeoutMs + Integer readTimeoutMs, + Integer connectTimeoutMs ) { public FrankfurterProperties { if (baseUrl == null || baseUrl.isBlank()) { baseUrl = "https://api.frankfurter.app"; } if (readTimeoutMs == null || readTimeoutMs <= 0) { readTimeoutMs = 5000; } + if (connectTimeoutMs == null || connectTimeoutMs <= 0) { + connectTimeoutMs = 3000; + } } }📝 Committable suggestion
🤖 Prompt for AI Agents