spring-cloud/spring-cloud-commons

Service host is not properly resolved when using both @LoadBalanced and DiscoveryClient

NadChel opened this issue · 3 comments

I have a simple app consisting of three microservices communicating via Eureka. It works when retrieving host and port info from DiscoveryClient directly but fails to do so if I choose to (partially) rely on @LoadBalanced instead. Here's the code

EurekaServer

@SpringBootApplication
@EnableEurekaServer
public class EurekaServerApplication {
    public static void main(String[] args) {
        SpringApplication.run(EurekaServerApplication.class, args);
    }
}
server.port=8761
eureka.server.enable-self-preservation=false
eureka.client.register-with-eureka=false
eureka.client.fetch-registry=false
<!-- pom.xml files trimmed for brevity -->

    <properties>
        <java.version>17</java.version>
        <spring-cloud.version>2022.0.4</spring-cloud.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${spring-cloud.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

PlanetMicroservice

@SpringBootApplication
public class PlanetMicroserviceApplication {
    public static void main(String[] args) {
        SpringApplication.run(PlanetMicroserviceApplication.class, args);
    }
}
@Configuration
@ComponentScan
public class Config {
    @Bean
    public RestTemplate restTemplate(RestTemplateBuilder restTemplateBuilder) {
        return restTemplateBuilder.build();
    }
}
@NoArgsConstructor
@Setter
@Getter
@ToString
public class Planet {
    private String name;
    private String gravity;
    private String terrain;
}
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class PlanetController {
    private final PlanetService planetService;
    @GetMapping("/planets")
    public Planet getPlanet(@RequestParam String name) {
        return planetService.getPlanet(name);
    }
}
@NoArgsConstructor
@Setter
@Getter
public class PlanetResults {
    private List<Planet> results;
    Planet getFirst() {
        return results.get(0);
    }
}
@Service
@RequiredArgsConstructor
public class PlanetService {
    private final RestTemplate restTemplate;
    private static final String BASE_URL = "https://swapi.dev/api/planets";
    public Planet getPlanet(String name) {
        return Objects.requireNonNull(restTemplate.getForObject(BASE_URL + "?search=" + name, PlanetResults.class)).getFirst();
    }
}
server.port=8081
spring.application.name=planet-ms
<!-- it's the same for ConverterMiscroservice so I'll omit it -->

    <properties>
        <java.version>17</java.version>
        <spring-cloud.version>2022.0.4</spring-cloud.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
            <version>4.0.3</version>
        </dependency>
    </dependencies>
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${spring-cloud.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

ConverterMicroserice

@SpringBootApplication
public class ConverterMicroserviceApplication {
    public static void main(String[] args) {
        SpringApplication.run(ConverterMicroserviceApplication.class, args);
    }
}
@Configuration
@ComponentScan
public class Config {
}
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class ConverterController {
    private final ConverterService converterService;
    @GetMapping("/kilos")
    public Double getKilograms(@RequestParam Double pounds) {
        return converterService.getKilos(pounds);
    }
}
@Service
public class ConverterService {
    private static final Double KILOS_IN_POUNDS = 0.45;

    public Double getKilos(Double pounds) {
        return pounds * KILOS_IN_POUNDS;
    }
}
server.port=8082
spring.application.name=converter-ms

WeightOnPlanetMicroservice

@SpringBootApplication
public class WeightOnPlanetMicroserviceApplication {
    public static void main(String[] args) {
        SpringApplication.run(WeightOnPlanetMicroserviceApplication.class, args);
    }
}
@Configuration
@ComponentScan("com.example.weightonplanetmicroservice")
public class Config {
    @Bean
    @LoadBalanced
    public RestTemplate restTemplate(RestTemplateBuilder restTemplateBuilder) {
        return restTemplateBuilder.build();
    }
}
@RestController
@RequestMapping("/api/planet-weight")
@RequiredArgsConstructor
public class WeightOnPlanetController {
    private final PlanetService planetService;
    private final WeightService weightService;
    private final RoundingUtil ru;

    @GetMapping("/{planet}")
    public PlanetWeightResponse getWeightOnPlanet(@PathVariable("planet") String planetName,
                                                  @RequestParam String weight) {
        var planet = planetService.getPlanet(planetName);
        Optional<Double> optionalPlanetGravity = planet.getGravityValue();
        if (optionalPlanetGravity.isPresent()) {
            double weightInKilos = weightService.getWeightInKilos(weight);
            double weightOnPlanet = weightInKilos * optionalPlanetGravity.get();
            return new PlanetWeightResponse(ru.round(weightInKilos),
                    ru.round(weightOnPlanet), planet.getName(),
                    WeightUnit.KILOS);
        } else {
            throw new RuntimeException("Gravity value not available");
        }
    }
}
@RequiredArgsConstructor
public enum WeightUnit {
    KILOS("kg"), POUNDS("lb");
    private final String abbreviation;
    @JsonValue
    @Override
    public String toString() {
        return abbreviation;
    }
}
@Setter
@Getter
@AllArgsConstructor
@NoArgsConstructor
public class Planet {
    private String name;
    private String gravity;
    private String terrain;

    public Optional<Double> getGravityValue() {
        try {
            return Optional.of(Double.valueOf(gravity.split(" ")[0]));
        } catch (NumberFormatException e) {
            return Optional.empty();
        }
    }
}
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Setter
public class PlanetWeightResponse {
    @JsonProperty("earth_weight")
    private Double earthWeight;
    @JsonProperty("planet_weight")
    private Double planetWeight;
    private String planet;
    @JsonProperty("weight_unit")
    private WeightUnit unit;
}
@Service
public class PlanetService {
    private final RestTemplate restTemplate;

    public PlanetService(@LoadBalanced RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
    }

    public Planet getPlanet(String name) {
        return restTemplate.getForObject("http://planet-ms/api/planets?name=" + name,
                Planet.class);
    }
}
@Service
@RequiredArgsConstructor
public class WeightService {
    private final RestTemplate restTemplate;
    private final MicroserviceUtil mu; // this time I don't use `@LoadBalanced`
    public Double getWeightInKilos(String weight) {
        String[] weightAndUnit = weight.split("(?<=\\d)(?=[a-z]+)");
        Double value = Double.valueOf(weightAndUnit[0]);
        String unit = weightAndUnit[1];
        if (unit.equals(WeightUnit.KILOS.toString())) {
            return value;
        } else if (unit.equals(WeightUnit.POUNDS.toString())) {
            return restTemplate.getForObject(String.format("http://%s/api/kilos?pounds=%s",
                    mu.getHostAndPort("converter-ms"), value), Double.class);
        }
        throw new IllegalArgumentException("Unsupported unit");
    }
}
@Component
@RequiredArgsConstructor
public class MicroserviceUtil {
    private final DiscoveryClient discoveryClient;
    public String getHostAndPort(String microserviceName) {
        var converterMicroservice = discoveryClient.getInstances(microserviceName).get(0);
        return converterMicroservice.getHost() + ":" + converterMicroservice.getPort();
    }
}
@Component
public class RoundingUtil {
    public double round(double value) {
        return DoubleRounder.round(value, 2);
    }
}
server.port=8080
spring.application.name=weight-on-planet-ms
    <properties>
        <java.version>17</java.version>
        <spring-cloud.version>2022.0.4</spring-cloud.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
            <version>4.0.3</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-loadbalancer</artifactId>
            <version>4.0.4</version>
        </dependency>

    </dependencies>
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${spring-cloud.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

What I get without @LoadBalanced (example URI):

{
  "planet": "Polis Massa",
  "earth_weight": 54.0,
  "planet_weight": 30.24,
  "weight_unit": "kg"
}

What I get with @LoadBalanced (I omitted the @ControllerAdvice code):

{
  "error_message": "No instances available for host.docker.internal"
}
WARN 5288 --- [nio-8080-exec-4] o.s.c.l.core.RoundRobinLoadBalancer      : No servers available for service: host.docker.internal

While writing this issue, I accidentally discovered a way to fix it. I should change this

            return restTemplate.getForObject(String.format("http://%s/api/kilos?pounds=%s",
                    mu.getHostAndPort("converter-ms"), value), Double.class);

to this

return restTemplate.getForObject("http://converter-ms/api/kilos?pounds=" + value, 
                    Double.class);

In other words, for some reason, if I make requests to one microservice using @LoadBalanced and to another one using DiscoveryClient directly, I get an exception. If I choose to use only one or the other method, it works okay. It doesn't feel right

Hello @NadChel please provide a minimal, complete, verifiable example that reproduces the issue, as a link to a GH repo with executable app/ tests, not just pasted code snippets.

If you would like us to look at this issue, please provide the requested information. If the information is not provided within the next 7 days this issue will be closed.

Closing due to lack of requested feedback. If you would like us to look at this issue, please provide the requested information and we will re-open the issue.