| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899 |
- using System.Text;
- using Microsoft.Extensions.Logging;
- namespace Infrastructure.StockData;
- /// <summary>
- /// SEIBro OpenAPI(seibro.or.kr/OpenPlatform/callOpenAPI.jsp) 호출 공통 — query param key 인증 + 간단 3회 재시도 (2s/4s 백오프).
- /// GET {BaseUrl}?key={ApiKey}&apiId={apiId}&params={K:V,K2:V2}
- /// • params 는 콜론(:)/콤마(,) 구분 단일 문자열 — SEIBro 비표준 규격이라 구분자(콜론·콤마)는 raw 로 보내고,
- /// 키·값만 개별 Uri.EscapeDataString 처리한다. 근거: KRX(KrxCoKrHttp)는 값이 전부 코드/날짜라 raw 조립이지만
- /// SEIBro 도 값이 코드/날짜/ISIN 이라 escape 는 사실상 no-op — 다만 향후 예약문자(& 공백 등)가 값에 들어와도
- /// query 가 깨지지 않도록 방어한다. 구분자까지 escape 하면(%3A/%2C) SEIBro 가 params 를 못 읽으므로 절대 금지 (docs/SEIBro/readme.md §1).
- /// • https 연결 실패(재시도 소진) 시 http 로 1회 fallback — SEIBro 가이드 원문이 http 엔드포인트라 환경에 따라 https 미지원 가능성 대비.
- /// • 응답 Content-Type charset 감지 디코딩 — EUC-KR 가능성 대비 CodePagesEncodingProvider 등록
- /// (.NET 에서 euc-kr 등 코드페이지 인코딩은 기본 미등록 — System.Text.Encoding.CodePages 는 shared framework 포함).
- /// • 로그에 인증 key 는 절대 남기지 않는다 — apiId·params 만 기록 (KrxCoKrHttp 의 key 비로깅 정책과 동일).
- /// </summary>
- internal static class SeibroHttp
- {
- public const string ClientName = "Seibro";
- private const int MaxAttempts = 3;
- static SeibroHttp()
- {
- // euc-kr/ks_c_5601-1987 등 한국어 코드페이지 디코딩 지원 (idempotent)
- Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
- }
- /// <summary>호출 URL 조립 — key 는 마지막에 붙여 로그용 문자열과 분리하기 쉽게 한다. 값 단위 escape, 구분자 raw.</summary>
- public static string BuildParams(IReadOnlyList<KeyValuePair<string, string>> parameters)
- {
- return string.Join(',', parameters.Select(c => $"{Uri.EscapeDataString(c.Key)}:{Uri.EscapeDataString(c.Value)}"));
- }
- public static async Task<string> GetStringWithRetryAsync(HttpClient client, string baseUrl, string apiKey, string apiId, IReadOnlyList<KeyValuePair<string, string>> parameters, ILogger logger, CancellationToken ct)
- {
- var paramString = BuildParams(parameters);
- var url = $"{baseUrl}?key={Uri.EscapeDataString(apiKey)}&apiId={Uri.EscapeDataString(apiId)}¶ms={paramString}";
- try
- {
- return await GetWithRetryAsync(client, url, apiId, paramString, logger, ct);
- }
- catch (HttpRequestException ex) when (url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
- {
- // https 연결 불가 → http 로 1회 fallback (공개 데이터 + 재시도 소진 후 최후 수단)
- var httpUrl = string.Concat("http://", url.AsSpan("https://".Length));
- logger.LogWarning(ex, "[Seibro] https 실패 — http fallback 1회 시도 (apiId={ApiId}, params={Params})", apiId, paramString);
- using var request = new HttpRequestMessage(HttpMethod.Get, httpUrl);
- using var response = await client.SendAsync(request, ct);
- response.EnsureSuccessStatusCode();
- return await ReadContentAsync(response, logger, ct);
- }
- }
- private static async Task<string> GetWithRetryAsync(HttpClient client, string url, string apiId, string paramString, ILogger logger, CancellationToken ct)
- {
- for (var attempt = 1; ; attempt++)
- {
- try
- {
- using var request = new HttpRequestMessage(HttpMethod.Get, url);
- using var response = await client.SendAsync(request, ct);
- response.EnsureSuccessStatusCode();
- return await ReadContentAsync(response, logger, ct);
- }
- catch (Exception ex) when (ex is not OperationCanceledException && attempt < MaxAttempts)
- {
- // key 마스킹: URL 대신 apiId/params 만 로깅
- logger.LogWarning(ex, "[Seibro] HTTP 실패 — 재시도 {Attempt}/{Max} (apiId={ApiId}, params={Params})", attempt, MaxAttempts, apiId, paramString);
- await Task.Delay(TimeSpan.FromSeconds(2 * attempt), ct);
- }
- }
- }
- /// <summary>Content-Type charset 감지 디코딩 — charset 미표기/미지원이면 UTF-8 (샘플 실응답 기준 기본값)</summary>
- private static async Task<string> ReadContentAsync(HttpResponseMessage response, ILogger logger, CancellationToken ct)
- {
- var bytes = await response.Content.ReadAsByteArrayAsync(ct);
- var charset = response.Content.Headers.ContentType?.CharSet?.Trim('"', '\'');
- var encoding = Encoding.UTF8;
- if (!string.IsNullOrWhiteSpace(charset))
- {
- try
- {
- encoding = Encoding.GetEncoding(charset);
- }
- catch (ArgumentException)
- {
- logger.LogWarning("[Seibro] 미지원 charset '{Charset}' — UTF-8 로 디코딩", charset);
- }
- }
- return encoding.GetString(bytes);
- }
- }
|