Preserve route proxy rate-limit semantics

Narrow the Naver Maps proxy contract to JSON reverse geocode responses and preserve upstream quota signals so client fallback can make accurate decisions.

Constraint: PR #282 review requested TDD fixes for XML contract mismatch, upstream 429 mapping, lint coverage, and route option documentation.

Rejected: XML passthrough in this follow-up | It would require a separate response-shaping contract and tests beyond the JSON proxy boundary.

Confidence: high

Scope-risk: narrow

Directive: Keep Naver Maps auth failures sanitized as 503 without upstream body snippets while preserving non-auth diagnostic snippets.

Tested: node --test packages/k-skill-proxy/test/server.test.js; node --test scripts/skill-docs.test.js; bash scripts/validate-skills.sh; PYENV_VERSION=3.12.0 npm run ci; architect verification CLEAR

Not-tested: Live NCP Maps calls with production credentials

Co-authored-by: OmX <omx@oh-my-codex.dev>
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-05-23 19:56:28 +09:00
commit 68bd64ebd4
5 changed files with 51 additions and 6 deletions

View file

@ -45,7 +45,7 @@
4. live 모드:
- 주소만 있으면 `/v1/naver-map/geocode` 로 좌표를 얻는다.
- `/v1/naver-map/directions` 로 경로를 조회한다.
- 응답의 `route.trafast[0].summary` 를 거리/시간/통행료/연료비로 매핑한다.
- 기본 `option=trafast` 응답은 `route.trafast[0].summary` 를, 다른 option을 명시한 경우 `route[option][0].summary` 를 거리/시간/통행료/연료비로 매핑한다.
5. live 실패(503/502/네트워크) 시 mock fallback 으로 떨어지고, 사용자에게 fallback 임을 명시한다.
## 예시
@ -90,6 +90,7 @@ curl -fsS --get "${BASE}/v1/naver-map/directions" \
- 키 누락(`503 upstream_not_configured`) → mock fallback + 사용자에게 안내
- 인증 실패(401/403) → proxy 가 `503` 으로 변환 → mock fallback
- quota/rate-limit(429) → proxy 가 `429 upstream_error` 로 보존 → mock fallback + 재시도 간격 안내
- 경로 미발견(`code != 0`) → `502 upstream_semantic_error` → 메시지와 함께 안내
- 네트워크 실패 → `502 upstream_error` → mock fallback
- 좌표 형식 오류 → `400 bad_request`

View file

@ -118,7 +118,7 @@ curl -fsS --get "${BASE}/v1/naver-map/directions" \
--data-urlencode 'option=trafast'
```
응답에서 `route.trafast[0].summary` 를 읽어 다음으로 매핑한다:
응답에서 기본 `option=trafast` 기준 `route.trafast[0].summary` 를 읽고, 다른 option을 명시한 경우 `route[option][0].summary` 다음으로 매핑한다:
- `distance` (meter) → `distance_km = distance / 1000`
- `duration` (millisecond) → `duration_minutes = duration / 60000`
@ -159,6 +159,7 @@ curl -fsS --get "${BASE}/v1/naver-map/geocode" \
- proxy upstream key 미설정 (`NAVER_MAP_CLIENT_ID/SECRET` 없음) → `503 upstream_not_configured` → mock fallback
- NCP Maps 인증 실패 (401/403) → proxy가 `503` 으로 변환 → mock fallback
- NCP Maps quota/rate-limit (`429`) → proxy가 `429 upstream_error` 로 보존 → mock fallback + 재시도 간격 안내
- 경로 미발견 (`code != 0`) → `502 upstream_semantic_error` → 메시지와 함께 안내
- 좌표 형식 오류 → `400 bad_request`
- 네트워크 실패 → `502 upstream_error` → mock fallback

View file

@ -9,7 +9,7 @@
"node": ">=18"
},
"scripts": {
"lint": "node --check src/airkorea.js && node --check src/bluer.js && node --check src/hrfco.js && node --check src/krx-stock.js && node --check src/kstartup.js && node --check src/lh-notice.js && node --check src/mfds.js && node --check src/molit.js && node --check src/naver-news.js && node --check src/naver-shopping.js && node --check src/nts-business.js && node --check src/parking-lots.js && node --check src/region-lookup.js && node --check src/server.js && node --check test/airkorea.test.js && node --check test/hrfco.test.js && node --check test/lh-notice.test.js && node --check test/molit.test.js && node --check test/naver-news.test.js && node --check test/naver-shopping.test.js && node --check test/region-lookup.test.js && node --check test/server.test.js",
"lint": "node --check src/airkorea.js && node --check src/bluer.js && node --check src/hrfco.js && node --check src/krx-stock.js && node --check src/kstartup.js && node --check src/lh-notice.js && node --check src/mfds.js && node --check src/molit.js && node --check src/naver-map.js && node --check src/naver-news.js && node --check src/naver-shopping.js && node --check src/nts-business.js && node --check src/parking-lots.js && node --check src/region-lookup.js && node --check src/server.js && node --check test/airkorea.test.js && node --check test/hrfco.test.js && node --check test/lh-notice.test.js && node --check test/molit.test.js && node --check test/naver-news.test.js && node --check test/naver-shopping.test.js && node --check test/region-lookup.test.js && node --check test/server.test.js",
"test": "node --test"
},
"dependencies": {

View file

@ -53,7 +53,7 @@ function createNaverMapHttpError(serviceName, responseStatus, bodyText) {
const error = new Error(`Naver Maps ${serviceName} upstream returned an error.`);
error.code = "upstream_error";
const isAuthError = responseStatus === 401 || responseStatus === 403;
error.statusCode = isAuthError ? 503 : 502;
error.statusCode = isAuthError ? 503 : responseStatus === 429 ? 429 : 502;
error.upstreamStatusCode = responseStatus;
if (!isAuthError) {
error.upstreamBodySnippet = bodyText.slice(0, 200);
@ -136,8 +136,8 @@ function normalizeNaverMapReverseGeocodeQuery(query) {
}
const output = trimOrNull(query.output) || "json";
if (output !== "json" && output !== "xml") {
throw new Error("Provide output as json or xml.");
if (output !== "json") {
throw new Error("Provide output as json. XML passthrough is not supported by this proxy.");
}
return { coords, orders, output };

View file

@ -909,6 +909,41 @@ test("Naver Map endpoints sanitize upstream auth errors as 503 without leaking t
}
});
test("Naver Map endpoints preserve upstream 429 for caller backoff", async (t) => {
const originalFetch = global.fetch;
global.fetch = async () => new Response("quota exceeded diagnostic", {
status: 429,
headers: { "content-type": "text/plain" }
});
const app = buildServer({
env: {
NAVER_MAP_CLIENT_ID: "id",
NAVER_MAP_CLIENT_SECRET: "secret"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const cases = [
"/v1/naver-map/directions?start=126.9,37.5&goal=127.0,37.5",
"/v1/naver-map/geocode?q=%EC%84%9C%EC%9A%B8%EC%97%AD",
"/v1/naver-map/reverse-geocode?coords=126.9,37.5"
];
for (const url of cases) {
const response = await app.inject({ method: "GET", url });
assert.equal(response.statusCode, 429);
const body = response.json();
assert.equal(body.error, "upstream_error");
assert.equal(body.upstream.status_code, 429);
assert.equal(body.upstream.body_snippet, "quota exceeded diagnostic");
}
});
test("Naver Map directions endpoint keeps non-auth upstream snippets for diagnostics", async (t) => {
const originalFetch = global.fetch;
global.fetch = async () => new Response("Transient upstream diagnostic", {
@ -1019,6 +1054,14 @@ test("Naver Map reverse-geocode endpoint validates coords and orders", async (t)
url: "/v1/naver-map/reverse-geocode?coords=127.0,37.5&orders=banana"
});
assert.equal(badOrder.statusCode, 400);
const xmlOutput = await app.inject({
method: "GET",
url: "/v1/naver-map/reverse-geocode?coords=127.0,37.5&output=xml"
});
assert.equal(xmlOutput.statusCode, 400);
assert.equal(xmlOutput.json().error, "bad_request");
assert.match(xmlOutput.json().message, /output as json/);
});
test("Naver Map health endpoint reflects naverMapConfigured flag", async (t) => {